diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go new file mode 100644 index 0000000000..1134eb1407 --- /dev/null +++ b/models/blog/blog_post.go @@ -0,0 +1,208 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + "fmt" + + "code.gitcaddy.com/server/v3/models/db" + repo_model "code.gitcaddy.com/server/v3/models/repo" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogPostStatus represents the publication state of a blog post. +type BlogPostStatus int //revive:disable-line:exported + +const ( + BlogPostDraft BlogPostStatus = 0 + BlogPostPublic BlogPostStatus = 1 + BlogPostPublished BlogPostStatus = 2 +) + +// String returns a human-readable label for the blog post status. +func (s BlogPostStatus) String() string { + switch s { + case BlogPostDraft: + return "draft" + case BlogPostPublic: + return "public" + case BlogPostPublished: + return "published" + default: + return "unknown" + } +} + +// 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"` + FeaturedImageID int64 `xorm:"DEFAULT 0"` + Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"` + 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:"-"` + FeaturedImage *repo_model.Attachment `xorm:"-"` +} + +func init() { + db.RegisterModel(new(BlogPost)) +} + +// LoadAuthor loads the author user for a blog post. +func (p *BlogPost) LoadAuthor(ctx context.Context) error { + if p.Author != nil { + return nil + } + u, err := user_model.GetUserByID(ctx, p.AuthorID) + if err != nil { + return err + } + p.Author = u + return nil +} + +// LoadRepo loads the repository for a blog post. +func (p *BlogPost) LoadRepo(ctx context.Context) error { + if p.Repo != nil { + return nil + } + r, err := repo_model.GetRepositoryByID(ctx, p.RepoID) + if err != nil { + return err + } + p.Repo = r + return nil +} + +// LoadFeaturedImage loads the featured image attachment. +func (p *BlogPost) LoadFeaturedImage(ctx context.Context) error { + if p.FeaturedImage != nil || p.FeaturedImageID == 0 { + return nil + } + a, err := repo_model.GetAttachmentByID(ctx, p.FeaturedImageID) + if err != nil { + return err + } + p.FeaturedImage = a + return nil +} + +// BlogPostSearchOptions contains filters for querying blog posts. +type BlogPostSearchOptions struct { //revive:disable-line:exported + RepoID int64 + AuthorID int64 + Status BlogPostStatus + AnyPublicStatus bool // if true, matches Public OR Published + Page int + PageSize int +} + +// GetBlogPostByID returns a single blog post by ID. +func GetBlogPostByID(ctx context.Context, id int64) (*BlogPost, error) { + p := &BlogPost{} + has, err := db.GetEngine(ctx).ID(id).Get(p) + if err != nil { + return nil, err + } + if !has { + return nil, fmt.Errorf("blog post %d not found", id) + } + return p, nil +} + +// GetBlogPostsByRepoID returns blog posts for a repo with filters and pagination. +func GetBlogPostsByRepoID(ctx context.Context, opts *BlogPostSearchOptions) ([]*BlogPost, int64, error) { + sess := db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID) + + if opts.AnyPublicStatus { + sess = sess.And("status >= ?", BlogPostPublic) + } else if opts.Status >= 0 { + sess = sess.And("status = ?", opts.Status) + } + + count, err := sess.Count(new(BlogPost)) + if err != nil { + return nil, 0, err + } + + // Re-create session for find (xorm reuses conditions) + sess = db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID) + if opts.AnyPublicStatus { + sess = sess.And("status >= ?", BlogPostPublic) + } else if opts.Status >= 0 { + sess = sess.And("status = ?", opts.Status) + } + + pageSize := opts.PageSize + if pageSize <= 0 { + pageSize = 20 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + posts := make([]*BlogPost, 0, pageSize) + err = sess.OrderBy("created_unix DESC"). + Limit(pageSize, (page-1)*pageSize). + Find(&posts) + return posts, count, err +} + +// GetPublishedBlogPosts returns published blog posts across all repos. +func GetPublishedBlogPosts(ctx context.Context, page, pageSize int) ([]*BlogPost, int64, error) { + if pageSize <= 0 { + pageSize = 20 + } + if page <= 0 { + page = 1 + } + + count, err := db.GetEngine(ctx).Where("status = ?", BlogPostPublished).Count(new(BlogPost)) + if err != nil { + return nil, 0, err + } + + posts := make([]*BlogPost, 0, pageSize) + err = db.GetEngine(ctx).Where("status = ?", BlogPostPublished). + OrderBy("published_unix DESC"). + Limit(pageSize, (page-1)*pageSize). + Find(&posts) + return posts, count, err +} + +// 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)) +} + +// CreateBlogPost inserts a new blog post. +func CreateBlogPost(ctx context.Context, p *BlogPost) error { + _, err := db.GetEngine(ctx).Insert(p) + return err +} + +// UpdateBlogPost updates an existing blog post. +func UpdateBlogPost(ctx context.Context, p *BlogPost) error { + _, err := db.GetEngine(ctx).ID(p.ID).AllCols().Update(p) + return err +} + +// DeleteBlogPost removes a blog post by ID. +func DeleteBlogPost(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(new(BlogPost)) + return err +} diff --git a/models/blog/blog_subscription.go b/models/blog/blog_subscription.go new file mode 100644 index 0000000000..e484b1586b --- /dev/null +++ b/models/blog/blog_subscription.go @@ -0,0 +1,59 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogSubscription represents a user's subscription to a repo's blog. +type BlogSubscription struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` + RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +func init() { + db.RegisterModel(new(BlogSubscription)) +} + +// IsSubscribedToBlog checks if a user is subscribed to a repo's blog. +func IsSubscribedToBlog(ctx context.Context, userID, repoID int64) (bool, error) { + return db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Exist(new(BlogSubscription)) +} + +// SubscribeToBlog adds a blog subscription. +func SubscribeToBlog(ctx context.Context, userID, repoID int64) error { + exists, err := IsSubscribedToBlog(ctx, userID, repoID) + if err != nil { + return err + } + if exists { + return nil + } + _, err = db.GetEngine(ctx).Insert(&BlogSubscription{ + UserID: userID, + RepoID: repoID, + }) + return err +} + +// UnsubscribeFromBlog removes a blog subscription. +func UnsubscribeFromBlog(ctx context.Context, userID, repoID int64) error { + _, err := db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Delete(new(BlogSubscription)) + return err +} + +// GetBlogSubscriberIDs returns the user IDs subscribed to a repo's blog. +func GetBlogSubscriberIDs(ctx context.Context, repoID int64) ([]int64, error) { + ids := make([]int64, 0, 64) + return ids, db.GetEngine(ctx).Table("blog_subscription"). + Where("repo_id = ?", repoID). + Select("user_id"). + Find(&ids) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 423c1efede..c4d14a0503 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -423,6 +423,9 @@ func prepareMigrationTasks() []*migration { newMigration(346, "Create repo_subscription_product table", v1_26.CreateRepoSubscriptionProductTable), newMigration(347, "Create repo_subscription table for user subscriptions", v1_26.CreateRepoSubscriptionTable), newMigration(348, "Add subscriptions_enabled to repository", v1_26.AddSubscriptionsEnabledToRepository), + newMigration(349, "Create blog_post table", v1_26.CreateBlogPostTable), + newMigration(350, "Create blog_subscription table", v1_26.CreateBlogSubscriptionTable), + newMigration(351, "Add blog_enabled to repository", v1_26.AddBlogEnabledToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v349.go b/models/migrations/v1_26/v349.go new file mode 100644 index 0000000000..9c78ef9543 --- /dev/null +++ b/models/migrations/v1_26/v349.go @@ -0,0 +1,30 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogPostTable creates the blog_post table. +func CreateBlogPostTable(x *xorm.Engine) error { + type BlogPost struct { + 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"` + Tags string `xorm:"TEXT"` + FeaturedImageID int64 `xorm:"DEFAULT 0"` + Status int `xorm:"SMALLINT NOT NULL DEFAULT 0"` + PublishedUnix timeutil.TimeStamp `xorm:"INDEX"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + return x.Sync(new(BlogPost)) +} diff --git a/models/migrations/v1_26/v350.go b/models/migrations/v1_26/v350.go new file mode 100644 index 0000000000..6214cb0704 --- /dev/null +++ b/models/migrations/v1_26/v350.go @@ -0,0 +1,22 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogSubscriptionTable creates the blog_subscription table. +func CreateBlogSubscriptionTable(x *xorm.Engine) error { + type BlogSubscription struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` + RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + return x.Sync(new(BlogSubscription)) +} diff --git a/models/migrations/v1_26/v351.go b/models/migrations/v1_26/v351.go new file mode 100644 index 0000000000..60cfd39b71 --- /dev/null +++ b/models/migrations/v1_26/v351.go @@ -0,0 +1,15 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +// AddBlogEnabledToRepository adds a flag to enable blog per repository. +func AddBlogEnabledToRepository(x *xorm.Engine) error { + type Repository struct { + BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` + } + + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index d4a838e6c8..7e0f92bc81 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -219,6 +219,7 @@ type Repository struct { SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"` Topics []string `xorm:"TEXT JSON"` SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"` + BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"` TrustModel TrustModelType diff --git a/modules/setting/config.go b/modules/setting/config.go index b36f4791bb..26e8238ed8 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -67,6 +67,7 @@ type ThemeStruct struct { CustomHomeTagline *config.Value[string] PinnedOrgDisplayFormat *config.Value[string] ExploreOrgDisplayFormat *config.Value[string] + EnableBlogs *config.Value[bool] } type ConfigStruct struct { @@ -105,6 +106,7 @@ func initDefaultConfig() { CustomHomeTagline: config.ValueJSON[string]("theme.custom_home_tagline").WithDefault(""), 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), }, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 42f7f1e316..1d2036f3f5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -363,6 +363,7 @@ "explore.users": "Users", "explore.organizations": "Organizations", "explore.packages": "Packages", + "explore.blogs": "Blogs", "explore.packages.empty.description": "No public or global packages are available yet.", "explore.go_to": "Go to", "explore.code": "Code", @@ -491,6 +492,9 @@ "mail.release.downloads": "Downloads:", "mail.release.download.zip": "Source Code (ZIP)", "mail.release.download.targz": "Source Code (TAR.GZ)", + "mail.blog.published_subject": "New blog post: \"%[1]s\" in %[2]s", + "mail.blog.published_body": "@%[1]s published a new blog post %[2]s in %[3]s", + "mail.blog.read_more": "Read More", "mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s", "mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you", "mail.repo.transfer.to_you": "you", @@ -1967,6 +1971,41 @@ "repo.signing.wont_sign.not_signed_in": "You are not signed in.", "repo.ext_wiki": "Access to External Wiki", "repo.ext_wiki.desc": "Link to an external wiki.", + "repo.blog": "Blog", + "repo.blog.new": "New Post", + "repo.blog.edit": "Edit Post", + "repo.blog.delete": "Delete Post", + "repo.blog.delete_confirm": "Are you sure you want to delete this blog post?", + "repo.blog.title": "Title", + "repo.blog.subtitle": "Subtitle", + "repo.blog.content": "Content", + "repo.blog.tags": "Tags", + "repo.blog.tags_placeholder": "Comma-separated tags", + "repo.blog.featured_image": "Featured Image", + "repo.blog.status": "Status", + "repo.blog.draft": "Draft", + "repo.blog.public": "Public", + "repo.blog.published": "Published", + "repo.blog.save_draft": "Save Draft", + "repo.blog.save_public": "Save Public", + "repo.blog.publish": "Publish", + "repo.blog.subscribe": "Subscribe to Blog", + "repo.blog.unsubscribe": "Unsubscribe", + "repo.blog.subscribed": "Subscribed", + "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", + "repo.blog.read_more": "Read More", + "repo.blog.by_author": "by %s", + "repo.blog.published_notification": "%s published a new blog post: %s", + "repo.blog.featured_image_uuid": "Attachment UUID", + "repo.blog.featured_image_help": "Enter the UUID of an uploaded attachment to use as the featured image.", + "repo.blog.content_placeholder": "Write your blog post content here...", + "repo.blog.tags_help": "Separate tags with commas.", + "repo.settings.blog": "Blog", + "repo.settings.blog.enable": "Enable Blog", + "repo.settings.blog.enable_desc": "Allow blog posts to be created and published from this repository.", + "repo.settings.blog.saved": "Blog settings saved.", "repo.wiki": "Wiki", "repo.wiki.welcome": "Welcome to the Wiki.", "repo.wiki.welcome_desc": "The wiki lets you write and share documentation with collaborators.", @@ -4001,6 +4040,8 @@ "admin.config.hide_explore_button_desc": "Hide the Explore button from the header navigation bar", "admin.config.enable_explore_packages": "Enable Explore Packages", "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.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 68638ea9c6..009f506e88 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -249,6 +249,7 @@ func ChangeConfig(ctx *context.Context) { cfg.Theme.CustomHomeTagline.DynKey(): marshalString(""), cfg.Theme.PinnedOrgDisplayFormat.DynKey(): marshalString("condensed"), cfg.Theme.ExploreOrgDisplayFormat.DynKey(): marshalString("list"), + cfg.Theme.EnableBlogs.DynKey(): marshalBool, } _ = ctx.Req.ParseForm() diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go new file mode 100644 index 0000000000..cde478e3bc --- /dev/null +++ b/routers/web/explore/blog.go @@ -0,0 +1,74 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/services/context" +) + +const tplExploreBlogs templates.TplName = "explore/blogs" + +// Blogs renders the explore blogs page with published posts across all repos. +func Blogs(ctx *context.Context) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.Redirect(setting.AppSubURL + "/explore") + return + } + + ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx) + ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage + ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage + ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = true + ctx.Data["Title"] = ctx.Tr("explore.blogs") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreBlogs"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + page := max(ctx.FormInt("page"), 1) + pageSize := setting.UI.IssuePagingNum + + posts, total, err := blog_model.GetPublishedBlogPosts(ctx, page, pageSize) + if err != nil { + ctx.ServerError("GetPublishedBlogPosts", 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 + } + } + + // Separate featured post (most recent) from the rest + if len(posts) > 0 { + ctx.Data["FeaturedPost"] = posts[0] + if len(posts) > 1 { + ctx.Data["Posts"] = posts[1:] + } + } + + ctx.Data["Total"] = total + + pager := context.NewPagination(int(total), pageSize, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplExploreBlogs) +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index d09e250a09..36806e4558 100644 --- a/routers/web/explore/code.go +++ b/routers/web/explore/code.go @@ -31,6 +31,7 @@ func Code(ctx *context.Context) { ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx) ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index fd029f2891..da3e05d6bb 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -22,6 +22,7 @@ func Organizations(ctx *context.Context) { ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx) ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExploreOrganizations"] = true diff --git a/routers/web/explore/package.go b/routers/web/explore/package.go index 1ad4997788..f858dc6d98 100644 --- a/routers/web/explore/package.go +++ b/routers/web/explore/package.go @@ -28,6 +28,7 @@ func Packages(ctx *context.Context) { ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = true + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExplorePackages"] = true diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index cbca085c67..57aa19ad17 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -154,6 +154,7 @@ func Repos(ctx *context.Context) { ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["ShowRepoOwnerOnList"] = true diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index 6384a8464b..944d7b618b 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -166,6 +166,7 @@ func Users(ctx *context.Context) { ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExploreUsers"] = true diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go new file mode 100644 index 0000000000..f7b78715a6 --- /dev/null +++ b/routers/web/repo/blog.go @@ -0,0 +1,418 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + "code.gitcaddy.com/server/v3/models/renderhelper" + 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/markup/markdown" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/timeutil" + "code.gitcaddy.com/server/v3/modules/web" + "code.gitcaddy.com/server/v3/services/context" + "code.gitcaddy.com/server/v3/services/forms" + "code.gitcaddy.com/server/v3/services/mailer" +) + +const ( + tplBlogList templates.TplName = "repo/blog/list" + tplBlogView templates.TplName = "repo/blog/view" + tplBlogEditor templates.TplName = "repo/blog/editor" +) + +func blogEnabled(ctx *context.Context) bool { + return ctx.Repo.Repository.BlogEnabled && setting.Config().Theme.EnableBlogs.Value(ctx) +} + +// BlogList renders the repo blog listing page. +func BlogList(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog") + ctx.Data["PageIsRepoBlog"] = true + + page := max(ctx.FormInt("page"), 1) + pageSize := setting.UI.IssuePagingNum + + opts := &blog_model.BlogPostSearchOptions{ + RepoID: ctx.Repo.Repository.ID, + Page: page, + PageSize: pageSize, + Status: -1, // all statuses for members + } + + // Non-members only see public + published + isWriter := ctx.Repo.CanWrite(unit.TypeCode) + if !isWriter { + opts.AnyPublicStatus = true + } + + posts, total, err := blog_model.GetBlogPostsByRepoID(ctx, opts) + if err != nil { + ctx.ServerError("GetBlogPostsByRepoID", err) + return + } + + for _, post := range posts { + if err := post.LoadAuthor(ctx); err != nil { + ctx.ServerError("LoadAuthor", err) + return + } + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + } + + // Featured post: most recent published + if len(posts) > 0 { + ctx.Data["FeaturedPost"] = posts[0] + if len(posts) > 1 { + ctx.Data["Posts"] = posts[1:] + } + } + + ctx.Data["IsWriter"] = isWriter + ctx.Data["Total"] = total + + // Blog subscription status + if ctx.Doer != nil { + subscribed, err := blog_model.IsSubscribedToBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("IsSubscribedToBlog", err) + return + } + ctx.Data["IsSubscribed"] = subscribed + } + + // Cross-promoted repos for right pane + crossPromoted, err := repo_model.GetCrossPromotedRepos(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetCrossPromotedRepos", err) + return + } + // Load target repos in batch + repoIDs := make([]int64, len(crossPromoted)) + for i, cp := range crossPromoted { + repoIDs[i] = cp.TargetRepoID + } + if len(repoIDs) > 0 { + repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + visible := make([]*repo_model.RepoCrossPromote, 0, len(crossPromoted)) + for _, cp := range crossPromoted { + if repo, ok := repos[cp.TargetRepoID]; ok { + cp.TargetRepo = repo + visible = append(visible, cp) + } + } + crossPromoted = visible + } + ctx.Data["CrossPromotedRepos"] = crossPromoted + + pager := context.NewPagination(int(total), pageSize, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplBlogList) +} + +// BlogView renders a single blog post. +func BlogView(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + // Verify post belongs to this repo + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + // Draft posts only visible to writers + isWriter := ctx.Repo.CanWrite(unit.TypeCode) + if post.Status == blog_model.BlogPostDraft && !isWriter { + 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 + } + + // Render markdown content + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) + 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, ",") + } + + ctx.Data["Title"] = post.Title + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["BlogPost"] = post + ctx.Data["IsWriter"] = isWriter + + ctx.HTML(http.StatusOK, tplBlogView) +} + +// BlogNew renders the new blog post form. +func BlogNew(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog.new") + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["IsNewPost"] = true + + ctx.HTML(http.StatusOK, tplBlogEditor) +} + +// BlogNewPost handles the new blog post form submission. +func BlogNewPost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + 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), + Status: blog_model.BlogPostStatus(form.Status), + } + + // Link featured image if provided + if form.FeaturedImage != "" { + attach, err := repo_model.GetAttachmentByUUID(ctx, form.FeaturedImage) + if err == nil { + post.FeaturedImageID = attach.ID + } + } + + // Set published timestamp on first publish + if post.Status == blog_model.BlogPostPublished { + post.PublishedUnix = timeutil.TimeStampNow() + } + + if err := blog_model.CreateBlogPost(ctx, post); err != nil { + ctx.ServerError("CreateBlogPost", err) + return + } + + // Trigger notifications on publish + if post.Status == blog_model.BlogPostPublished { + go notifyBlogPublished(ctx, post) + } + + // Redirect to the blog list since we don't have the ID from insert yet + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogEdit renders the edit blog post form. +func BlogEdit(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog.edit") + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["BlogPost"] = post + ctx.Data["IsNewPost"] = false + + ctx.HTML(http.StatusOK, tplBlogEditor) +} + +// BlogEditPost handles the edit blog post form submission. +func BlogEditPost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + form := web.GetForm(ctx).(*forms.BlogPostForm) + oldStatus := post.Status + + post.Title = form.Title + post.Subtitle = form.Subtitle + post.Content = form.Content + post.Tags = strings.TrimSpace(form.Tags) + post.Status = blog_model.BlogPostStatus(form.Status) + + // Link featured image if provided + if form.FeaturedImage != "" { + attach, err := repo_model.GetAttachmentByUUID(ctx, form.FeaturedImage) + if err == nil { + post.FeaturedImageID = attach.ID + } + } + + // Set published timestamp on first publish + if post.Status == blog_model.BlogPostPublished && post.PublishedUnix == 0 { + post.PublishedUnix = timeutil.TimeStampNow() + } + + if err := blog_model.UpdateBlogPost(ctx, post); err != nil { + ctx.ServerError("UpdateBlogPost", err) + return + } + + // Trigger notifications if status changed to published for the first time + if post.Status == blog_model.BlogPostPublished && oldStatus != blog_model.BlogPostPublished { + go notifyBlogPublished(ctx, post) + } + + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogDeletePost handles blog post deletion. +func BlogDeletePost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + if err := blog_model.DeleteBlogPost(ctx, post.ID); err != nil { + ctx.ServerError("DeleteBlogPost", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.blog.delete")) + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogSubscribe subscribes the current user to the repo's blog. +func BlogSubscribe(ctx *context.Context) { + if err := blog_model.SubscribeToBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("SubscribeToBlog", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogUnsubscribe unsubscribes the current user from the repo's blog. +func BlogUnsubscribe(ctx *context.Context) { + if err := blog_model.UnsubscribeFromBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("UnsubscribeFromBlog", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// notifyBlogPublished sends notifications to repo watchers and blog subscribers. +func notifyBlogPublished(ctx *context.Context, post *blog_model.BlogPost) { + if err := post.LoadRepo(ctx); err != nil { + return + } + if err := post.LoadAuthor(ctx); err != nil { + return + } + + // Collect recipients: repo watchers + blog subscribers (deduplicated) + watcherIDs, _ := repo_model.GetRepoWatchersIDs(ctx, post.RepoID) + subscriberIDs, _ := blog_model.GetBlogSubscriberIDs(ctx, post.RepoID) + + recipientSet := make(map[int64]struct{}) + for _, id := range watcherIDs { + recipientSet[id] = struct{}{} + } + for _, id := range subscriberIDs { + recipientSet[id] = struct{}{} + } + // Exclude author + delete(recipientSet, post.AuthorID) + + if len(recipientSet) == 0 { + return + } + + recipientIDs := make([]int64, 0, len(recipientSet)) + for id := range recipientSet { + recipientIDs = append(recipientIDs, id) + } + + recipients, _ := user_model.GetUsersByIDs(ctx, recipientIDs) + + // Send email notifications + if setting.MailService != nil { + _ = mailer.MailBlogPublished(ctx, post, recipients) + } +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 0950c8b5a3..a598cdfecd 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -525,6 +525,11 @@ func handleSettingsPostAdvanced(ctx *context.Context) { repoChanged = true } + if repo.BlogEnabled != form.EnableBlog { + repo.BlogEnabled = form.EnableBlog + repoChanged = true + } + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) } else if !unit_model.TypeCode.UnitGlobalDisabled() { diff --git a/routers/web/web.go b/routers/web/web.go index 039e6785f0..0f26862fd8 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -566,6 +566,7 @@ func registerWebRoutes(m *web.Router) { m.Get("/users/sitemap-{idx}.xml", sitemapEnabled, explore.Users) m.Get("/organizations", explore.Organizations) m.Get("/packages", explore.Packages) + m.Get("/blogs", explore.Blogs) m.Get("/code", func(ctx *context.Context) { if unit.TypeCode.UnitGlobalDisabled() { ctx.NotFound(nil) @@ -1709,6 +1710,23 @@ func registerWebRoutes(m *web.Router) { }) // end "/{username}/{reponame}/wiki" + m.Group("/{username}/{reponame}/blog", func() { + m.Get("", repo.BlogList) + m.Get("/{id}", repo.BlogView) + m.Group("", func() { + m.Get("/new", repo.BlogNew) + m.Post("/new", web.Bind(forms.BlogPostForm{}), repo.BlogNewPost) + m.Get("/{id}/edit", repo.BlogEdit) + m.Post("/{id}/edit", web.Bind(forms.BlogPostForm{}), repo.BlogEditPost) + m.Post("/{id}/delete", repo.BlogDeletePost) + }, reqSignIn, reqRepoCodeWriter) + m.Post("/subscribe", reqSignIn, repo.BlogSubscribe) + m.Post("/unsubscribe", reqSignIn, repo.BlogUnsubscribe) + }, optSignIn, context.RepoAssignment, func(ctx *context.Context) { + ctx.Data["PageIsRepoBlog"] = true + }) + // end "/{username}/{reponame}/blog" + m.Group("/{username}/{reponame}/pages", func() { m.Get("", pages.ServeRepoLandingPage) m.Get("/assets/*", pages.ServeRepoPageAsset) diff --git a/services/context/repo.go b/services/context/repo.go index 74680bf394..6b7c90bb1c 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -15,6 +15,7 @@ import ( "strings" asymkey_model "code.gitcaddy.com/server/v3/models/asymkey" + blog_model "code.gitcaddy.com/server/v3/models/blog" "code.gitcaddy.com/server/v3/models/db" git_model "code.gitcaddy.com/server/v3/models/git" issues_model "code.gitcaddy.com/server/v3/models/issues" @@ -558,6 +559,16 @@ func RepoAssignment(ctx *Context) { return } + // Blog post count for repo header tab + if repo.BlogEnabled && setting.Config().Theme.EnableBlogs.Value(ctx) { + blogCount, err := blog_model.CountPublishedBlogsByRepoID(ctx, repo.ID) + if err != nil { + ctx.ServerError("CountPublishedBlogsByRepoID", err) + return + } + ctx.Data["BlogPostCount"] = blogCount + } + ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName ctx.Data["Repository"] = repo diff --git a/services/forms/blog_form.go b/services/forms/blog_form.go new file mode 100644 index 0000000000..79d16a362e --- /dev/null +++ b/services/forms/blog_form.go @@ -0,0 +1,29 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package forms + +import ( + "net/http" + + "code.gitcaddy.com/server/v3/modules/web/middleware" + "code.gitcaddy.com/server/v3/services/context" + + "gitea.com/go-chi/binding" +) + +// 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)"` + FeaturedImage string // attachment UUID + Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published +} + +// Validate validates the fields +func (f *BlogPostForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e082bbcf23..a63a7a9c31 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -125,6 +125,7 @@ type RepoSettingForm struct { ExternalTrackerRegexpPattern string EnableCloseIssuesViaCommitInAnyBranch bool HideDotfiles bool + EnableBlog bool EnableProjects bool ProjectsMode string diff --git a/services/mailer/mail_blog.go b/services/mailer/mail_blog.go new file mode 100644 index 0000000000..53f8e74543 --- /dev/null +++ b/services/mailer/mail_blog.go @@ -0,0 +1,75 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/log" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/translation" + sender_service "code.gitcaddy.com/server/v3/services/mailer/sender" +) + +const tplBlogPublishedMail templates.TplName = "repo/blog" + +func generateMessageIDForBlogPost(post *blog_model.BlogPost) string { + return fmt.Sprintf("<%s/blog/%d@%s>", post.Repo.FullName(), post.ID, setting.Domain) +} + +// MailBlogPublished sends notification emails to recipients when a blog post is published. +func MailBlogPublished(_ context.Context, post *blog_model.BlogPost, recipients []*user_model.User) error { + if setting.MailService == nil { + return nil + } + + langMap := make(map[string][]*user_model.User) + for _, user := range recipients { + langMap[user.Language] = append(langMap[user.Language], user) + } + + for lang, tos := range langMap { + mailBlogPublished(lang, tos, post) + } + + return nil +} + +func mailBlogPublished(lang string, tos []*user_model.User, post *blog_model.BlogPost) { + locale := translation.NewLocale(lang) + + subject := locale.TrString("mail.blog.published_subject", post.Title, post.Repo.FullName()) + blogURL := fmt.Sprintf("%s%s/blog/%d", setting.AppURL, post.Repo.FullName(), post.ID) + + mailMeta := map[string]any{ + "locale": locale, + "Post": post, + "Subject": subject, + "Language": locale.Language(), + "Link": blogURL, + } + + var mailBody bytes.Buffer + if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplBlogPublishedMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplBlogPublishedMail)+"/body", err) + return + } + + msgs := make([]*sender_service.Message, 0, len(tos)) + publisherName := fromDisplayName(post.Author) + msgID := generateMessageIDForBlogPost(post) + for _, to := range tos { + msg := sender_service.NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String()) + msg.Info = subject + msg.SetHeader("Message-ID", msgID) + msgs = append(msgs, msg) + } + + SendAsync(msgs...) +} diff --git a/templates/admin/config_settings/theme.tmpl b/templates/admin/config_settings/theme.tmpl index 1ef4f83591..e22b6ef886 100644 --- a/templates/admin/config_settings/theme.tmpl +++ b/templates/admin/config_settings/theme.tmpl @@ -31,6 +31,13 @@
+
{{ctx.Locale.Tr "admin.config.enable_blogs"}}
+
+
+ +
+
+
{{ctx.Locale.Tr "admin.config.help_url"}}
diff --git a/templates/explore/blogs.tmpl b/templates/explore/blogs.tmpl new file mode 100644 index 0000000000..57ba17a984 --- /dev/null +++ b/templates/explore/blogs.tmpl @@ -0,0 +1,253 @@ +{{template "base/head" .}} + + + +{{template "base/footer" .}} diff --git a/templates/explore/navbar.tmpl b/templates/explore/navbar.tmpl index e3a122ea18..3ece7f3ea9 100644 --- a/templates/explore/navbar.tmpl +++ b/templates/explore/navbar.tmpl @@ -18,6 +18,11 @@ {{svg "octicon-package"}} {{ctx.Locale.Tr "explore.packages"}} {{end}} + {{if .BlogsPageIsEnabled}} + + {{svg "octicon-note" 16}} {{ctx.Locale.Tr "explore.blogs"}} + + {{end}} {{if and (not ctx.Consts.RepoUnitTypeCode.UnitGlobalDisabled) .IsRepoIndexerEnabled (not .CodePageIsDisabled)}} {{svg "octicon-code"}} {{ctx.Locale.Tr "explore.code"}} diff --git a/templates/mail/repo/blog.tmpl b/templates/mail/repo/blog.tmpl new file mode 100644 index 0000000000..050e927067 --- /dev/null +++ b/templates/mail/repo/blog.tmpl @@ -0,0 +1,32 @@ + + + + + {{.Subject}} + + +{{$blog_url := HTMLFormat "%s" .Link .Post.Title}} +{{$repo_url := HTMLFormat "%s" .Post.Repo.HTMLURL .Post.Repo.FullName}} + +

+ {{.locale.Tr "mail.blog.published_body" .Post.Author.Name $blog_url $repo_url}} +

+ {{if .Post.Subtitle}} +

{{.Post.Subtitle}}

+ {{end}} +
+

+ + {{.locale.Tr "mail.blog.read_more"}} + +

+
+
+

+ --- +
+ {{.locale.Tr "mail.view_it_on" AppName}}. +

+
+ + diff --git a/templates/repo/blog/editor.tmpl b/templates/repo/blog/editor.tmpl new file mode 100644 index 0000000000..1efc2bf404 --- /dev/null +++ b/templates/repo/blog/editor.tmpl @@ -0,0 +1,77 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+

+ {{if .IsNewPost}} + {{ctx.Locale.Tr "repo.blog.new"}} + {{else}} + {{ctx.Locale.Tr "repo.blog.edit"}} + {{end}} +

+ {{template "base/alert" .}} + + + {{.CsrfTokenHtml}} + +
+ + +
+ +
+ + +
+ +
+ + {{if and .BlogPost .BlogPost.FeaturedImage}} +
+ +
+ {{end}} + +
{{ctx.Locale.Tr "repo.blog.featured_image_help"}}
+
+ +
+ + {{template "shared/combomarkdowneditor" (dict + "MarkdownPreviewInRepo" $.Repository + "MarkdownPreviewMode" "comment" + "TextareaName" "content" + "TextareaContent" (Iif .BlogPost .BlogPost.Content "") + "TextareaPlaceholder" (ctx.Locale.Tr "repo.blog.content_placeholder") + "DropzoneParentContainer" "form" + )}} +
+ +
+ + +
{{ctx.Locale.Tr "repo.blog.tags_help"}}
+
+ +
+ {{ctx.Locale.Tr "cancel"}} + + + +
+ +
+
+{{template "base/footer" .}} diff --git a/templates/repo/blog/list.tmpl b/templates/repo/blog/list.tmpl new file mode 100644 index 0000000000..08637261d5 --- /dev/null +++ b/templates/repo/blog/list.tmpl @@ -0,0 +1,410 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} + + {{if .FeaturedPost}} + + {{end}} + +
+
+ {{if .Posts}} +
+ {{range .Posts}} + + {{if .FeaturedImage}} +
+ {{.Title}} +
+ {{else}} +
+ {{svg "octicon-note" 24}} +
+ {{end}} +
+
+ {{if and (eq .Status 0) $.IsWriter}} + {{ctx.Locale.Tr "repo.blog.draft"}} + {{end}} +

{{.Title}}

+
+ {{if .Subtitle}} +

{{.Subtitle}}

+ {{end}} +
+ {{if .Author}} + {{.Author.Name}} + {{.Author.GetDisplayName}} + · + {{end}} + {{DateUtils.TimeSince .CreatedUnix}} +
+
+
+ {{end}} +
+ {{end}} + + {{if and (not .FeaturedPost) (not .Posts)}} +
+ {{svg "octicon-note" 48}} + {{if .IsWriter}} +

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

+ {{else}} +

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

+ {{end}} +
+ {{end}} + + {{template "base/paginate" .}} +
+ +
+ {{if .IsWriter}} + + {{svg "octicon-plus" 16}} {{ctx.Locale.Tr "repo.blog.new"}} + + {{end}} + + {{if ctx.Doer}} +
+ {{if .IsSubscribed}} +
+ {{.CsrfTokenHtml}} + +
+ {{else}} +
+ {{.CsrfTokenHtml}} + +
+ {{end}} +
+ {{end}} + + {{if .CrossPromotedRepos}} + + {{end}} +
+
+
+
+ + +{{template "base/footer" .}} diff --git a/templates/repo/blog/view.tmpl b/templates/repo/blog/view.tmpl new file mode 100644 index 0000000000..3fffe94f44 --- /dev/null +++ b/templates/repo/blog/view.tmpl @@ -0,0 +1,142 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+
+ {{if .BlogPost.FeaturedImage}} +
+ {{.BlogPost.Title}} +
+ {{end}} + +
+
+ {{if and (eq .BlogPost.Status 0) .IsWriter}} + {{ctx.Locale.Tr "repo.blog.draft"}} + {{end}} +

{{.BlogPost.Title}}

+ {{if .BlogPost.Subtitle}} +

{{.BlogPost.Subtitle}}

+ {{end}} +
+ {{if .BlogPost.Author}} + {{.BlogPost.Author.Name}} + {{.BlogPost.Author.GetDisplayName}} + · + {{end}} + {{if .BlogPost.PublishedUnix}} + {{DateUtils.TimeSince .BlogPost.PublishedUnix}} + {{else}} + {{DateUtils.TimeSince .BlogPost.CreatedUnix}} + {{end}} +
+ {{if .BlogTags}} +
+ {{range .BlogTags}} + {{.}} + {{end}} +
+ {{end}} +
+ +
+ {{.BlogPost.RenderedContent | SafeHTML}} +
+
+ + {{if .IsWriter}} +
+ + {{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.blog.edit"}} + +
+ {{.CsrfTokenHtml}} + +
+
+ {{end}} +
+
+
+ + +{{template "base/footer" .}} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 3023bf7dd2..934174978c 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -213,6 +213,15 @@ {{end}} + {{if and .Repository.BlogEnabled (ctx.RootData.SystemConfig.Theme.EnableBlogs.Value ctx)}} + {{if or .IsWriter (gt .BlogPostCount 0)}} + + {{svg "octicon-note"}} {{ctx.Locale.Tr "repo.blog"}} + {{if gt .BlogPostCount 0}}{{.BlogPostCount}}{{end}} + + {{end}} + {{end}} + {{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}} {{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index f28fecae1d..f4d05e4a99 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -353,6 +353,17 @@ + {{if ctx.RootData.SystemConfig.Theme.EnableBlogs.Value ctx}} +
+
+ +
+ + +
+
+ {{end}} +
{{$isIssuesEnabled := or (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeIssues) (.Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeExternalTracker)}}