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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:13:39 +08:00
co-authored by Claude Fable 5
parent 42516257e0
commit b474ee009a
7 changed files with 329 additions and 89 deletions
+217 -77
View File
@@ -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")
}
}