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;