From 6bc3693cef6be6dd41f65e3022ca68c3d7f53040 Mon Sep 17 00:00:00 2001 From: logikonline Date: Mon, 2 Feb 2026 13:04:30 -0500 Subject: [PATCH] feat(blog): add series support and v2 API endpoints Adds blog series field to group related posts together. Implements v2 API endpoints for listing, creating, updating, and deleting blog posts with proper error codes. Adds series filtering to explore page and sitemap support with pagination. Includes BlogPostV2 structs with author/repo references, HTML URLs, and content rendering. Updates editor UI with series input field. --- models/blog/blog_post.go | 28 +++ modules/errors/codes.go | 10 + modules/structs/repo_blog_v2.go | 72 ++++++ options/locale/locale_en-US.json | 4 + routers/api/v2/api.go | 14 ++ routers/api/v2/blog.go | 350 ++++++++++++++++++++++++++++ routers/web/explore/blog.go | 37 ++- routers/web/home.go | 15 ++ routers/web/repo/blog.go | 2 + routers/web/web.go | 1 + services/forms/blog_form.go | 1 + templates/blog/standalone_view.tmpl | 17 ++ templates/explore/blogs.tmpl | 22 +- templates/repo/blog/editor.tmpl | 7 + templates/repo/blog/view.tmpl | 17 ++ 15 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 modules/structs/repo_blog_v2.go create mode 100644 routers/api/v2/blog.go diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go index 2fd7330693..8e6c3de26f 100644 --- a/models/blog/blog_post.go +++ b/models/blog/blog_post.go @@ -51,6 +51,7 @@ type BlogPost struct { //revive:disable-line:exported 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"` @@ -198,6 +199,7 @@ type ExploreBlogPostsOptions struct { SortType string // "newest" (default) or "popular" Keyword string // search in title/subtitle Tag string // filter by tag (exact match within comma-separated tags) + Series string // filter by series name (exact match) } // exploreBlogBaseSess creates a base session with blog status and repo access conditions. @@ -236,6 +238,11 @@ func GetExploreBlogPosts(ctx context.Context, opts *ExploreBlogPostsOptions) ([] findSess = findSess.And("blog_post.tags LIKE ?", tagPattern) } + if opts.Series != "" { + countSess = countSess.And("blog_post.series = ?", opts.Series) + findSess = findSess.And("blog_post.series = ?", opts.Series) + } + count, err := countSess.Count(new(BlogPost)) if err != nil { return nil, 0, err @@ -308,6 +315,27 @@ func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ( 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)) +} + +// GetPublicBlogPostsPage returns a page of public/published blog posts for sitemap generation. +func GetPublicBlogPostsPage(ctx context.Context, page, pageSize int) ([]*BlogPost, error) { + if pageSize <= 0 { + pageSize = 20 + } + if page <= 0 { + page = 1 + } + posts := make([]*BlogPost, 0, pageSize) + err := db.GetEngine(ctx).Where("status >= ?", BlogPostPublic). + OrderBy("id ASC"). + Limit(pageSize, (page-1)*pageSize). + Find(&posts) + return posts, 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)) diff --git a/modules/errors/codes.go b/modules/errors/codes.go index fff1f15bcc..9183c89356 100644 --- a/modules/errors/codes.go +++ b/modules/errors/codes.go @@ -157,6 +157,12 @@ const ( WikiDisabled ErrorCode = "WIKI_DISABLED" ) +// Blog errors (BLOG_) +const ( + BlogPostNotFound ErrorCode = "BLOG_POST_NOT_FOUND" + BlogDisabled ErrorCode = "BLOG_DISABLED" +) + // errorInfo contains metadata about an error code type errorInfo struct { Message string @@ -277,6 +283,10 @@ var errorCatalog = map[ErrorCode]errorInfo{ WikiPageAlreadyExists: {"Wiki page already exists", http.StatusConflict}, WikiReservedName: {"Wiki page name is reserved", http.StatusBadRequest}, WikiDisabled: {"Wiki is disabled for this repository", http.StatusForbidden}, + + // Blog errors + BlogPostNotFound: {"Blog post not found", http.StatusNotFound}, + BlogDisabled: {"Blogs are disabled", http.StatusForbidden}, } // Message returns the human-readable message for an error code diff --git a/modules/structs/repo_blog_v2.go b/modules/structs/repo_blog_v2.go new file mode 100644 index 0000000000..e48934e6cd --- /dev/null +++ b/modules/structs/repo_blog_v2.go @@ -0,0 +1,72 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// BlogPostV2 represents a blog post in v2 API format +type BlogPostV2 struct { + ID int64 `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + Series string `json:"series,omitempty"` + Content string `json:"content,omitempty"` + ContentHTML string `json:"content_html,omitempty"` + Tags []string `json:"tags"` + Status string `json:"status"` + AllowComments bool `json:"allow_comments"` + FeaturedImageURL string `json:"featured_image_url,omitempty"` + Author *BlogAuthorV2 `json:"author,omitempty"` + Repo *BlogRepoRefV2 `json:"repo,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PublishedAt *time.Time `json:"published_at,omitempty"` + HTMLURL string `json:"html_url"` +} + +// BlogAuthorV2 represents the author of a blog post +type BlogAuthorV2 struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +// BlogRepoRefV2 is a minimal repo reference in blog responses +type BlogRepoRefV2 struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` +} + +// BlogPostListV2 represents a paginated list of blog posts +type BlogPostListV2 struct { + Posts []*BlogPostV2 `json:"posts"` + TotalCount int64 `json:"total_count"` + HasMore bool `json:"has_more"` +} + +// CreateBlogPostV2Option represents options for creating a blog post +type CreateBlogPostV2Option struct { + Title string `json:"title" binding:"Required;MaxSize(255)"` + Subtitle string `json:"subtitle" binding:"MaxSize(500)"` + Series string `json:"series" binding:"MaxSize(255)"` + Content string `json:"content" binding:"Required"` + Tags []string `json:"tags"` + Status string `json:"status"` // draft, public, published + AllowComments bool `json:"allow_comments"` + FeaturedImageUUID string `json:"featured_image_uuid"` +} + +// UpdateBlogPostV2Option represents options for updating a blog post +type UpdateBlogPostV2Option struct { + Title *string `json:"title" binding:"MaxSize(255)"` + Subtitle *string `json:"subtitle" binding:"MaxSize(500)"` + Series *string `json:"series" binding:"MaxSize(255)"` + Content *string `json:"content"` + Tags []string `json:"tags"` + Status *string `json:"status"` // draft, public, published + AllowComments *bool `json:"allow_comments"` + FeaturedImageUUID *string `json:"featured_image_uuid"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 16d83f93fc..0e031eb2aa 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -368,6 +368,7 @@ "explore.blogs.sort_popular": "Most Popular", "explore.blogs.all_posts": "All Posts", "explore.blogs.filtered_by_tag": "Tag: %s", + "explore.blogs.filtered_by_series": "Series: %s", "explore.blogs.clear_filter": "Clear", "explore.blogs.search_results": "Results for \"%s\"", "explore.blogs.search_placeholder": "Search articles...", @@ -2019,6 +2020,9 @@ "repo.blog.editor_image_hint": "Drag & drop or paste images directly into the editor. Use > for pull quotes.", "repo.blog.content_placeholder": "Write your blog post content here...", "repo.blog.tags_help": "Separate tags with commas.", + "repo.blog.series": "Series", + "repo.blog.series.placeholder": "e.g. Getting Started with Go", + "repo.blog.series.help": "Group related posts together under a series name.", "repo.blog.share_link": "Share Link", "repo.blog.link_copied": "Link Copied!", "repo.blog.allow_comments": "Allow Comments", diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index f2f1e60dff..cf4322cfa8 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -175,6 +175,20 @@ func Routes() *web.Router { m.Get("/content", repoAssignmentWithPublicAccess(), GetPagesContent) }) + // Blog v2 API - repository blog endpoints + m.Group("/repos/{owner}/{repo}/blog/posts", func() { + // Public read endpoints (access checked in handler) + m.Get("", repoAssignment(), ListBlogPostsV2) + m.Get("/{id}", repoAssignment(), GetBlogPostV2) + + // Write endpoints require authentication + m.Group("", func() { + m.Post("", repoAssignment(), web.Bind(api.CreateBlogPostV2Option{}), CreateBlogPostV2) + m.Put("/{id}", repoAssignment(), web.Bind(api.UpdateBlogPostV2Option{}), UpdateBlogPostV2) + m.Delete("/{id}", repoAssignment(), DeleteBlogPostV2) + }, reqToken()) + }) + // Hidden folders API - manage hidden folders for a repository m.Group("/repos/{owner}/{repo}/hidden-folders", func() { m.Get("", repoAssignment(), ListHiddenFoldersV2) diff --git a/routers/api/v2/blog.go b/routers/api/v2/blog.go new file mode 100644 index 0000000000..e728487e10 --- /dev/null +++ b/routers/api/v2/blog.go @@ -0,0 +1,350 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "fmt" + "net/http" + "strings" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/models/unit" + apierrors "code.gitcaddy.com/server/v3/modules/errors" + "code.gitcaddy.com/server/v3/modules/setting" + api "code.gitcaddy.com/server/v3/modules/structs" + "code.gitcaddy.com/server/v3/modules/timeutil" + "code.gitcaddy.com/server/v3/modules/web" + "code.gitcaddy.com/server/v3/services/context" +) + +func blogPostStatusFromString(s string) (blog_model.BlogPostStatus, bool) { + switch strings.ToLower(s) { + case "draft", "": + return blog_model.BlogPostDraft, true + case "public": + return blog_model.BlogPostPublic, true + case "published": + return blog_model.BlogPostPublished, true + default: + return 0, false + } +} + +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(), + } + + if post.Tags != "" { + for t := range strings.SplitSeq(post.Tags, ",") { + t = strings.TrimSpace(t) + if t != "" { + result.Tags = append(result.Tags, t) + } + } + } + + if post.PublishedUnix > 0 { + t := post.PublishedUnix.AsTime() + result.PublishedAt = &t + } + + if post.Author != nil { + result.Author = &api.BlogAuthorV2{ + ID: post.Author.ID, + Username: post.Author.Name, + Name: post.Author.DisplayName(), + AvatarURL: post.Author.AvatarLink(ctx), + } + } + + if post.Repo != nil { + result.Repo = &api.BlogRepoRefV2{ + ID: post.Repo.ID, + FullName: post.Repo.FullName(), + HTMLURL: setting.AppURL + post.Repo.FullName(), + } + result.HTMLURL = fmt.Sprintf("%s%s/blog/%d", setting.AppURL, post.Repo.FullName(), post.ID) + } + + if post.FeaturedImage != nil { + result.FeaturedImageURL = post.FeaturedImage.DownloadURL() + } + + return result +} + +// ListBlogPostsV2 lists blog posts for a repository +func ListBlogPostsV2(ctx *context.APIContext) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.APIErrorWithCode(apierrors.BlogDisabled) + return + } + + repo := ctx.Repo.Repository + canWrite := ctx.Repo.Permission.CanWrite(unit.TypeCode) + + page := max(ctx.FormInt("page"), 1) + limit := ctx.FormInt("limit") + if limit <= 0 { + limit = setting.API.DefaultPagingNum + } + if limit > 100 { + limit = 100 + } + + opts := &blog_model.BlogPostSearchOptions{ + RepoID: repo.ID, + Page: page, + PageSize: limit, + } + + if !canWrite { + opts.AnyPublicStatus = true + } + + posts, totalCount, err := blog_model.GetBlogPostsByRepoID(ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiPosts := make([]*api.BlogPostV2, 0, len(posts)) + for _, post := range posts { + _ = post.LoadAuthor(ctx) + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) + apiPosts = append(apiPosts, toBlogPostV2(ctx, post)) + } + + ctx.JSON(http.StatusOK, &api.BlogPostListV2{ + Posts: apiPosts, + TotalCount: totalCount, + HasMore: int64(page*limit) < totalCount, + }) +} + +// GetBlogPostV2 gets a single blog post by ID +func GetBlogPostV2(ctx *context.APIContext) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.APIErrorWithCode(apierrors.BlogDisabled) + return + } + + repo := ctx.Repo.Repository + postID := ctx.PathParamInt64("id") + + post, err := blog_model.GetBlogPostByID(ctx, postID) + if err != nil { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + if post.RepoID != repo.ID { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + canWrite := ctx.Repo.Permission.CanWrite(unit.TypeCode) + if !canWrite && post.Status < blog_model.BlogPostPublic { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + _ = post.LoadAuthor(ctx) + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) + + ctx.JSON(http.StatusOK, toBlogPostV2(ctx, post)) +} + +// CreateBlogPostV2 creates a new blog post +func CreateBlogPostV2(ctx *context.APIContext) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.APIErrorWithCode(apierrors.BlogDisabled) + return + } + + repo := ctx.Repo.Repository + + if !ctx.Repo.Permission.CanWrite(unit.TypeCode) { + ctx.APIErrorWithCode(apierrors.PermRepoWriteDenied) + return + } + + form := web.GetForm(ctx).(*api.CreateBlogPostV2Option) + + status, ok := blogPostStatusFromString(form.Status) + if !ok { + ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{ + "field": "status", + "error": "must be one of: draft, public, published", + }) + return + } + + 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, + } + + if status == blog_model.BlogPostPublished { + post.PublishedUnix = timeutil.TimeStampNow() + } + + // Link featured image + if form.FeaturedImageUUID != "" { + attach, err := repo_model.GetAttachmentByUUID(ctx, form.FeaturedImageUUID) + if err == nil && attach.RepoID == repo.ID { + post.FeaturedImageID = attach.ID + } + } + + if err := blog_model.CreateBlogPost(ctx, post); err != nil { + ctx.APIErrorInternal(err) + return + } + + _ = post.LoadAuthor(ctx) + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) + + ctx.JSON(http.StatusCreated, toBlogPostV2(ctx, post)) +} + +// UpdateBlogPostV2 updates an existing blog post +func UpdateBlogPostV2(ctx *context.APIContext) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.APIErrorWithCode(apierrors.BlogDisabled) + return + } + + repo := ctx.Repo.Repository + + if !ctx.Repo.Permission.CanWrite(unit.TypeCode) { + ctx.APIErrorWithCode(apierrors.PermRepoWriteDenied) + return + } + + postID := ctx.PathParamInt64("id") + post, err := blog_model.GetBlogPostByID(ctx, postID) + if err != nil { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + if post.RepoID != repo.ID { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + form := web.GetForm(ctx).(*api.UpdateBlogPostV2Option) + + if form.Title != nil { + post.Title = *form.Title + } + if form.Subtitle != nil { + post.Subtitle = *form.Subtitle + } + if form.Series != nil { + post.Series = strings.TrimSpace(*form.Series) + } + if form.Content != nil { + post.Content = *form.Content + } + if form.Tags != nil { + post.Tags = strings.Join(form.Tags, ",") + } + if form.AllowComments != nil { + post.AllowComments = *form.AllowComments + } + if form.Status != nil { + status, ok := blogPostStatusFromString(*form.Status) + if !ok { + ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{ + "field": "status", + "error": "must be one of: draft, public, published", + }) + return + } + if status == blog_model.BlogPostPublished && post.PublishedUnix == 0 { + post.PublishedUnix = timeutil.TimeStampNow() + } + post.Status = status + } + + // Link featured image + if form.FeaturedImageUUID != nil { + if *form.FeaturedImageUUID == "" { + post.FeaturedImageID = 0 + } else { + attach, err := repo_model.GetAttachmentByUUID(ctx, *form.FeaturedImageUUID) + if err == nil && attach.RepoID == repo.ID { + post.FeaturedImageID = attach.ID + } + } + } + + if err := blog_model.UpdateBlogPost(ctx, post); err != nil { + ctx.APIErrorInternal(err) + return + } + + _ = post.LoadAuthor(ctx) + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) + + ctx.JSON(http.StatusOK, toBlogPostV2(ctx, post)) +} + +// DeleteBlogPostV2 deletes a blog post +func DeleteBlogPostV2(ctx *context.APIContext) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.APIErrorWithCode(apierrors.BlogDisabled) + return + } + + repo := ctx.Repo.Repository + + if !ctx.Repo.Permission.CanWrite(unit.TypeCode) { + ctx.APIErrorWithCode(apierrors.PermRepoWriteDenied) + return + } + + postID := ctx.PathParamInt64("id") + post, err := blog_model.GetBlogPostByID(ctx, postID) + if err != nil { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + if post.RepoID != repo.ID { + ctx.APIErrorWithCode(apierrors.BlogPostNotFound) + return + } + + if err := blog_model.DeleteBlogPost(ctx, post.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go index 4913076ae6..3ec19b2fc2 100644 --- a/routers/web/explore/blog.go +++ b/routers/web/explore/blog.go @@ -5,6 +5,7 @@ package explore import ( "net/http" + "strconv" "strings" "time" @@ -12,8 +13,10 @@ import ( access_model "code.gitcaddy.com/server/v3/models/perm/access" "code.gitcaddy.com/server/v3/models/renderhelper" "code.gitcaddy.com/server/v3/models/unit" + "code.gitcaddy.com/server/v3/modules/log" "code.gitcaddy.com/server/v3/modules/markup/markdown" "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/sitemap" "code.gitcaddy.com/server/v3/modules/templates" "code.gitcaddy.com/server/v3/services/context" @@ -62,6 +65,9 @@ func Blogs(ctx *context.Context) { tag := ctx.FormTrim("tag") ctx.Data["Tag"] = tag + series := ctx.FormTrim("series") + ctx.Data["Series"] = series + posts, total, err := blog_model.GetExploreBlogPosts(ctx, &blog_model.ExploreBlogPostsOptions{ Actor: ctx.Doer, Page: page, @@ -69,6 +75,7 @@ func Blogs(ctx *context.Context) { SortType: sortType, Keyword: keyword, Tag: tag, + Series: series, }) if err != nil { ctx.ServerError("GetExploreBlogPosts", err) @@ -85,8 +92,8 @@ func Blogs(ctx *context.Context) { _ = post.LoadFeaturedImage(ctx) } - // Only show featured post on page 1 with no search/tag filter - showFeatured := page == 1 && keyword == "" && tag == "" + // Only show featured post on page 1 with no search/tag/series filter + showFeatured := page == 1 && keyword == "" && tag == "" && series == "" if showFeatured && len(posts) > 0 { if counts, err := blog_model.GetBlogReactionCounts(ctx, posts[0].ID); err == nil { ctx.Data["FeaturedLikes"] = counts.Likes @@ -267,6 +274,32 @@ func StandaloneBlogView(ctx *context.Context) { ctx.HTML(http.StatusOK, tplStandaloneBlogView) } +// BlogSitemap renders a sitemap page for blog posts. +func BlogSitemap(ctx *context.Context) { + page := ctx.PathParamInt("idx") + if page <= 0 { + page = 1 + } + + posts, err := blog_model.GetPublicBlogPostsPage(ctx, page, setting.UI.SitemapPagingNum) + if err != nil { + ctx.ServerError("GetPublicBlogPostsPage", err) + return + } + + m := sitemap.NewSitemap() + for _, post := range posts { + m.Add(sitemap.URL{ + URL: setting.AppURL + "blog/" + strconv.FormatInt(post.ID, 10), + LastMod: post.UpdatedUnix.AsTimePtr(), + }) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing blog sitemap: %v", err) + } +} + // collectCommentIDs returns all comment IDs from top-level comments and their replies. func collectCommentIDs(comments []*blog_model.BlogComment) []int64 { var ids []int64 diff --git a/routers/web/home.go b/routers/web/home.go index 8f9acfaa88..b04e003cd6 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -8,6 +8,7 @@ import ( "net/http" "strconv" + blog_model "code.gitcaddy.com/server/v3/models/blog" "code.gitcaddy.com/server/v3/models/db" organization_model "code.gitcaddy.com/server/v3/models/organization" repo_model "code.gitcaddy.com/server/v3/models/repo" @@ -113,6 +114,20 @@ func HomeSitemap(ctx *context.Context) { idx++ } + if setting.Config().Theme.EnableBlogs.Value(ctx) { + blogCount, err := blog_model.CountPublicBlogPosts(ctx) + if err != nil { + ctx.ServerError("CountPublicBlogPosts", err) + return + } + blogTotal := int(blogCount) + blogIdx := 1 + for i := 0; i < blogTotal; i += setting.UI.SitemapPagingNum { + m.Add(sitemap.URL{URL: setting.AppURL + "explore/blogs/sitemap-" + strconv.Itoa(blogIdx) + ".xml"}) + blogIdx++ + } + } + ctx.Resp.Header().Set("Content-Type", "text/xml") if _, err := m.WriteTo(ctx.Resp); err != nil { log.Error("Failed writing sitemap: %v", err) diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go index 8d221d8be9..08470101ba 100644 --- a/routers/web/repo/blog.go +++ b/routers/web/repo/blog.go @@ -301,6 +301,7 @@ func BlogNewPost(ctx *context.Context) { 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, } @@ -389,6 +390,7 @@ func BlogEditPost(ctx *context.Context) { post.Subtitle = form.Subtitle post.Content = form.Content post.Tags = strings.TrimSpace(form.Tags) + post.Series = strings.TrimSpace(form.Series) post.Status = blog_model.BlogPostStatus(form.Status) post.AllowComments = form.AllowComments diff --git a/routers/web/web.go b/routers/web/web.go index f46c94a481..0ff90a383e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -568,6 +568,7 @@ func registerWebRoutes(m *web.Router) { m.Get("/organizations", explore.Organizations) m.Get("/packages", explore.Packages) m.Get("/blogs", explore.Blogs) + m.Get("/blogs/sitemap-{idx}.xml", sitemapEnabled, explore.BlogSitemap) m.Get("/code", func(ctx *context.Context) { if unit.TypeCode.UnitGlobalDisabled() { ctx.NotFound(nil) diff --git a/services/forms/blog_form.go b/services/forms/blog_form.go index 25514363e9..9943d43d7a 100644 --- a/services/forms/blog_form.go +++ b/services/forms/blog_form.go @@ -18,6 +18,7 @@ type BlogPostForm struct { 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 diff --git a/templates/blog/standalone_view.tmpl b/templates/blog/standalone_view.tmpl index bfbcc70ba3..9704dc8af5 100644 --- a/templates/blog/standalone_view.tmpl +++ b/templates/blog/standalone_view.tmpl @@ -23,6 +23,9 @@
+ {{if .BlogPost.Series}} + {{.BlogPost.Series}} + {{end}}

{{.BlogPost.Title}}

{{if .BlogPost.Subtitle}}

{{.BlogPost.Subtitle}}

@@ -326,6 +329,20 @@ padding-bottom: 24px; border-bottom: 1px solid var(--color-secondary-alpha-40); } +.blog-view-series { + display: inline-block; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-primary); + text-decoration: none; + margin-bottom: 4px; +} +.blog-view-series:hover { + text-decoration: underline; + color: var(--color-primary); +} .blog-view-title { font-size: 32px; font-weight: 700; diff --git a/templates/explore/blogs.tmpl b/templates/explore/blogs.tmpl index 4fee312f5b..3bd21e2e00 100644 --- a/templates/explore/blogs.tmpl +++ b/templates/explore/blogs.tmpl @@ -45,7 +45,10 @@

- {{if .Tag}} + {{if .Series}} + {{ctx.Locale.Tr "explore.blogs.filtered_by_series" .Series}} + {{ctx.Locale.Tr "explore.blogs.clear_filter"}} + {{else if .Tag}} {{ctx.Locale.Tr "explore.blogs.filtered_by_tag" .Tag}} {{ctx.Locale.Tr "explore.blogs.clear_filter"}} {{else if .Keyword}} @@ -55,10 +58,10 @@ {{end}}

@@ -75,6 +78,9 @@
{{end}}
+ {{if .Series}} + {{.Series}} + {{end}}

{{.Title}}

{{if .Subtitle}}

{{.Subtitle}}

@@ -122,6 +128,7 @@ {{if .Tag}}{{end}} + {{if .Series}}{{end}}
@@ -294,6 +301,15 @@ flex-direction: column; justify-content: center; } +.blog-list-item-series { + display: block; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-primary); + margin-bottom: 2px; +} .blog-list-item-title { font-size: 16px; font-weight: 600; diff --git a/templates/repo/blog/editor.tmpl b/templates/repo/blog/editor.tmpl index 053305ea0c..06e499e5c3 100644 --- a/templates/repo/blog/editor.tmpl +++ b/templates/repo/blog/editor.tmpl @@ -104,6 +104,13 @@ {{end}} + +
+ + +
{{ctx.Locale.Tr "repo.blog.series.help"}}
+
+
diff --git a/templates/repo/blog/view.tmpl b/templates/repo/blog/view.tmpl index 71f5b22d17..e92cd6f51e 100644 --- a/templates/repo/blog/view.tmpl +++ b/templates/repo/blog/view.tmpl @@ -14,6 +14,9 @@ {{if and (eq .BlogPost.Status 0) .IsWriter}} {{ctx.Locale.Tr "repo.blog.draft"}} {{end}} + {{if .BlogPost.Series}} + {{.BlogPost.Series}} + {{end}}

{{.BlogPost.Title}}

{{if .BlogPost.Subtitle}}

{{.BlogPost.Subtitle}}

@@ -291,6 +294,20 @@ padding-bottom: 24px; border-bottom: 1px solid var(--color-secondary-alpha-40); } +.blog-view-series { + display: inline-block; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-primary); + text-decoration: none; + margin-bottom: 4px; +} +.blog-view-series:hover { + text-decoration: underline; + color: var(--color-primary); +} .blog-view-title { font-size: 32px; font-weight: 700;