From b474ee009a7a651b2f0480ded33a417dec11e396 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 21 Jun 2026 20:13:39 +0800 Subject: [PATCH] feat: add article management (list, edit, delete) - Add admin article list page at /admin/articles with status badges, pin marker, publish time, and edit/delete actions - Share the create/edit form: dynamic action URL, prefilled fields, is_top checkbox state, and create-vs-edit heading - Add ArticleUpdate/ArticleDelete handlers (soft-delete via DeletedAt); stamp PublishedAt on first publish - Add "Manage Articles" entry on the dashboard - Hide home page sign-in/dashboard buttons - Add EN/ZH i18n keys for the management UI Co-Authored-By: Claude Fable 5 --- handlers/article.go | 294 ++++++++++++++++++++-------- i18n/i18n.go | 30 +++ main.go | 4 + templates/admin/article_create.html | 6 +- templates/admin/article_list.html | 60 ++++++ templates/admin/dashboard.html | 20 +- templates/pages/home.html | 4 +- 7 files changed, 329 insertions(+), 89 deletions(-) create mode 100644 templates/admin/article_list.html diff --git a/handlers/article.go b/handlers/article.go index e38c056..81ffdae 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" "regexp" + "strconv" "strings" "time" @@ -27,13 +28,99 @@ func generateSlug(title string) string { return s } +// articleForm holds the parsed article form fields, shared by the create and +// edit handlers and their validation-error repopulation paths. +type articleForm struct { + Title string + Slug string + Summary string + Content string + Cover string + StatusStr string + IsTop bool + Action string // form action URL + TitleText string // page heading text (create vs edit) +} + +// parseArticleForm reads and trims the article form fields from the request. +func parseArticleForm(c *gin.Context) articleForm { + return articleForm{ + Title: strings.TrimSpace(c.PostForm("title")), + Slug: strings.TrimSpace(c.PostForm("slug")), + Summary: strings.TrimSpace(c.PostForm("summary")), + Content: strings.TrimSpace(c.PostForm("content")), + Cover: strings.TrimSpace(c.PostForm("cover")), + StatusStr: c.PostForm("status"), + IsTop: c.PostForm("is_top") == "1", + } +} + +// applyFormToData writes the form field values into the template data map so +// the form is repopulated on render (initial load or validation error). +func applyFormToData(data gin.H, f articleForm) { + data["FormTitle"] = f.Title + data["FormSlug"] = f.Slug + data["FormSummary"] = f.Summary + data["FormContent"] = f.Content + data["FormCover"] = f.Cover + data["FormStatus"] = f.StatusStr + data["FormIsTop"] = f.IsTop + data["FormAction"] = f.Action + data["FormTitleText"] = f.TitleText +} + +// renderArticleForm renders the shared article form template with the given +// form values and optional error message. +func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) { + data := DefaultData(c) + data["Title"] = f.TitleText + if errMsg != "" { + data["Error"] = errMsg + } + applyFormToData(data, f) + c.HTML(http.StatusOK, "article_create", data) +} + +// sessionAuthorID extracts the logged-in user's ID from the session, defending +// against int/uint/int64/float64 storage. Returns ok=false if absent. +func sessionAuthorID(c *gin.Context) (uint, bool) { + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + return 0, false + } + switch v := userID.(type) { + case uint: + return v, true + case int: + return uint(v), true + case int64: + return uint(v), true + case float64: + return uint(v), true + default: + return 0, false + } +} + +// statusFromForm parses the status string ("0" draft, "1" published) and +// returns the model status constant, defaulting to draft. +func statusFromForm(statusStr string) int { + if statusStr == "1" { + return models.ArticlePublished + } + return models.ArticleDraft +} + // ArticleCreatePage renders the article creation form. func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) - data := DefaultData(c) - data["Title"] = tr["article_create_title"] - c.HTML(http.StatusOK, "article_create", data) + renderArticleForm(c, db, articleForm{ + Action: "/admin/articles/new", + TitleText: tr["article_create_title"], + StatusStr: "0", + }, "") } } @@ -41,75 +128,29 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { func ArticleCreate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) - - title := strings.TrimSpace(c.PostForm("title")) - content := strings.TrimSpace(c.PostForm("content")) - slug := strings.TrimSpace(c.PostForm("slug")) - summary := strings.TrimSpace(c.PostForm("summary")) - cover := strings.TrimSpace(c.PostForm("cover")) - statusStr := c.PostForm("status") - isTop := c.PostForm("is_top") == "1" + f := parseArticleForm(c) + f.Action = "/admin/articles/new" + f.TitleText = tr["article_create_title"] // Validate required fields. - if title == "" { - data := DefaultData(c) - data["Title"] = tr["article_create_title"] - data["Error"] = tr["article_title_required"] - data["FormTitle"] = title - data["FormSlug"] = slug - data["FormSummary"] = summary - data["FormContent"] = content - data["FormCover"] = cover - data["FormStatus"] = statusStr - c.HTML(http.StatusOK, "article_create", data) + if f.Title == "" { + renderArticleForm(c, db, f, tr["article_title_required"]) return } - if content == "" { - data := DefaultData(c) - data["Title"] = tr["article_create_title"] - data["Error"] = tr["article_content_required"] - data["FormTitle"] = title - data["FormSlug"] = slug - data["FormSummary"] = summary - data["FormContent"] = content - data["FormCover"] = cover - data["FormStatus"] = statusStr - c.HTML(http.StatusOK, "article_create", data) + if f.Content == "" { + renderArticleForm(c, db, f, tr["article_content_required"]) return } // Auto-generate slug if empty. - if slug == "" { - slug = generateSlug(title) + if f.Slug == "" { + f.Slug = generateSlug(f.Title) } - // Parse status: default to draft (0). - status := models.ArticleDraft - if statusStr == "1" { - status = models.ArticlePublished - } + status := statusFromForm(f.StatusStr) - // Get author ID from session. - session := sessions.Default(c) - userID := session.Get("user_id") - if userID == nil { - c.Redirect(http.StatusFound, "/login") - return - } - - // The session stores user.ID, which is a gorm uint — but be defensive - // across int / uint / int64 storage so a type mismatch never panics. - var authorID uint - switch v := userID.(type) { - case uint: - authorID = v - case int: - authorID = uint(v) - case int64: - authorID = uint(v) - case float64: - authorID = uint(v) - default: + authorID, ok := sessionAuthorID(c) + if !ok { c.Redirect(http.StatusFound, "/login") return } @@ -122,27 +163,18 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { article := models.Article{ AuthorID: authorID, - Title: title, - Slug: slug, - Summary: summary, - Content: content, - Cover: cover, + Title: f.Title, + Slug: f.Slug, + Summary: f.Summary, + Content: f.Content, + Cover: f.Cover, Status: status, - IsTop: isTop, + IsTop: f.IsTop, PublishedAt: publishedAt, } if err := db.Create(&article).Error; err != nil { - data := DefaultData(c) - data["Title"] = tr["article_create_title"] - data["Error"] = tr["article_error"] - data["FormTitle"] = title - data["FormSlug"] = slug - data["FormSummary"] = summary - data["FormContent"] = content - data["FormCover"] = cover - data["FormStatus"] = statusStr - c.HTML(http.StatusOK, "article_create", data) + renderArticleForm(c, db, f, tr["article_error"]) return } @@ -150,3 +182,111 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { c.Redirect(http.StatusFound, "/admin") } } + +// ArticleListPage renders the admin article management list. +func ArticleListPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + var articles []models.Article + db.Order("created_at DESC").Find(&articles) + data := DefaultData(c) + data["Title"] = tr["article_list_title"] + data["Articles"] = articles + c.HTML(http.StatusOK, "article_list", data) + } +} + +// ArticleEditPage renders the shared form prefilled with an existing article. +func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + + var article models.Article + if err := db.First(&article, "id = ?", id).Error; err != nil { + c.Redirect(http.StatusFound, "/admin/articles") + return + } + + renderArticleForm(c, db, articleForm{ + Title: article.Title, + Slug: article.Slug, + Summary: article.Summary, + Content: article.Content, + Cover: article.Cover, + StatusStr: strconv.Itoa(article.Status), + IsTop: article.IsTop, + Action: "/admin/articles/" + id + "/edit", + TitleText: tr["article_edit_title"], + }, "") + } +} + +// ArticleUpdate handles the POST request to update an existing article. +func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + + var article models.Article + if err := db.First(&article, "id = ?", id).Error; err != nil { + c.Redirect(http.StatusFound, "/admin/articles") + return + } + + f := parseArticleForm(c) + f.Action = "/admin/articles/" + id + "/edit" + f.TitleText = tr["article_edit_title"] + + if f.Title == "" { + renderArticleForm(c, db, f, tr["article_title_required"]) + return + } + if f.Content == "" { + renderArticleForm(c, db, f, tr["article_content_required"]) + return + } + + if f.Slug == "" { + f.Slug = generateSlug(f.Title) + } + + newStatus := statusFromForm(f.StatusStr) + + // Stamp the publish time the first time an article is published. + wasPublished := article.Status == models.ArticlePublished + var publishedAt *time.Time = article.PublishedAt + if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { + now := time.Now() + publishedAt = &now + } + + updates := map[string]interface{}{ + "Title": f.Title, + "Slug": f.Slug, + "Summary": f.Summary, + "Content": f.Content, + "Cover": f.Cover, + "Status": newStatus, + "IsTop": f.IsTop, + "PublishedAt": publishedAt, + } + + if err := db.Model(&article).Updates(updates).Error; err != nil { + renderArticleForm(c, db, f, tr["article_error"]) + return + } + + c.Redirect(http.StatusFound, "/admin/articles") + } +} + +// ArticleDelete soft-deletes an article (GORM fills DeletedAt) and redirects +// back to the management list. +func ArticleDelete(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + db.Delete(&models.Article{}, "id = ?", id) + c.Redirect(http.StatusFound, "/admin/articles") + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 5505502..93a0bd0 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -118,6 +118,21 @@ var translations = map[Lang]map[string]string{ "article_content_required": "Content is required.", "article_back_home": "Back to Home", "article_not_found": "Article not found.", + + // Article management + "article_list_title": "Articles", + "article_edit_title": "Edit Article", + "article_manage": "Manage Articles", + "article_edit": "Edit", + "article_delete": "Delete", + "article_delete_confirm": "Delete this article?", + "article_updated": "Article updated.", + "article_deleted": "Article deleted.", + "article_archived": "Archived", + "article_col_title": "Title", + "article_col_status": "Status", + "article_col_published": "Published", + "article_col_actions": "Actions", }, ZH: { // 导航 @@ -221,6 +236,21 @@ var translations = map[Lang]map[string]string{ "article_content_required": "正文不能为空。", "article_back_home": "返回首页", "article_not_found": "文章不存在。", + + // 文章管理 + "article_list_title": "文章管理", + "article_edit_title": "编辑文章", + "article_manage": "文章管理", + "article_edit": "编辑", + "article_delete": "删除", + "article_delete_confirm": "确认删除这篇文章?", + "article_updated": "文章已更新。", + "article_deleted": "文章已删除。", + "article_archived": "已归档", + "article_col_title": "标题", + "article_col_status": "状态", + "article_col_published": "发布时间", + "article_col_actions": "操作", }, } diff --git a/main.go b/main.go index 4ed578b..9e0e3e2 100644 --- a/main.go +++ b/main.go @@ -57,8 +57,12 @@ func main() { admin.Use(middleware.AuthRequired()) { admin.GET("", handlers.AdminDashboard(db)) + admin.GET("/articles", handlers.ArticleListPage(db)) admin.GET("/articles/new", handlers.ArticleCreatePage(db)) admin.POST("/articles/new", handlers.ArticleCreate(db)) + admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) + admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) + admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) } // Protected profile routes. diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html index f2dc481..152c399 100644 --- a/templates/admin/article_create.html +++ b/templates/admin/article_create.html @@ -2,7 +2,7 @@ {{template "header" .}}
-

{{index .Tr "article_create_title"}}

+

{{.FormTitleText}}

{{if .Error}}
@@ -10,7 +10,7 @@
{{end}} -
+
@@ -52,7 +52,7 @@
-
diff --git a/templates/admin/article_list.html b/templates/admin/article_list.html new file mode 100644 index 0000000..b8ba1d4 --- /dev/null +++ b/templates/admin/article_list.html @@ -0,0 +1,60 @@ +{{define "article_list"}} +{{template "header" .}} +
+
+

{{index .Tr "article_list_title"}}

+ + + + + {{index .Tr "dash_new_article"}} + +
+ +
+ + + + + + + + + + + {{range .Articles}} + + + + + + + {{else}} + + + + {{end}} + +
{{index .Tr "article_col_title"}}{{index .Tr "article_col_status"}}{{index .Tr "article_col_published"}}{{index .Tr "article_col_actions"}}
+ {{.Title}} + {{if .IsTop}}{{index $.Tr "article_is_top"}}{{end}} + + {{if eq .Status 1}}{{index $.Tr "article_published"}} + {{else if eq .Status 2}}{{index $.Tr "article_archived"}} + {{else}}{{index $.Tr "article_draft"}}{{end}} + + {{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02 15:04"}}{{else}}—{{end}} + + {{index $.Tr "article_edit"}} + + + +
{{index .Tr "home_no_posts"}}
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 74fbacf..28de5bf 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -38,13 +38,19 @@
{{template "footer" .}} diff --git a/templates/pages/home.html b/templates/pages/home.html index f06a691..0c2af08 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -7,14 +7,14 @@

{{index .Tr "home_subtitle"}}

-
+