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:
+217
-77
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,13 +28,99 @@ func generateSlug(title string) string {
|
|||||||
return s
|
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.
|
// ArticleCreatePage renders the article creation form.
|
||||||
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tr := getTr(c)
|
tr := getTr(c)
|
||||||
data := DefaultData(c)
|
renderArticleForm(c, db, articleForm{
|
||||||
data["Title"] = tr["article_create_title"]
|
Action: "/admin/articles/new",
|
||||||
c.HTML(http.StatusOK, "article_create", data)
|
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 {
|
func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tr := getTr(c)
|
tr := getTr(c)
|
||||||
|
f := parseArticleForm(c)
|
||||||
title := strings.TrimSpace(c.PostForm("title"))
|
f.Action = "/admin/articles/new"
|
||||||
content := strings.TrimSpace(c.PostForm("content"))
|
f.TitleText = tr["article_create_title"]
|
||||||
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"
|
|
||||||
|
|
||||||
// Validate required fields.
|
// Validate required fields.
|
||||||
if title == "" {
|
if f.Title == "" {
|
||||||
data := DefaultData(c)
|
renderArticleForm(c, db, f, tr["article_title_required"])
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if content == "" {
|
if f.Content == "" {
|
||||||
data := DefaultData(c)
|
renderArticleForm(c, db, f, tr["article_content_required"])
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate slug if empty.
|
// Auto-generate slug if empty.
|
||||||
if slug == "" {
|
if f.Slug == "" {
|
||||||
slug = generateSlug(title)
|
f.Slug = generateSlug(f.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse status: default to draft (0).
|
status := statusFromForm(f.StatusStr)
|
||||||
status := models.ArticleDraft
|
|
||||||
if statusStr == "1" {
|
|
||||||
status = models.ArticlePublished
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get author ID from session.
|
authorID, ok := sessionAuthorID(c)
|
||||||
session := sessions.Default(c)
|
if !ok {
|
||||||
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:
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,27 +163,18 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
article := models.Article{
|
article := models.Article{
|
||||||
AuthorID: authorID,
|
AuthorID: authorID,
|
||||||
Title: title,
|
Title: f.Title,
|
||||||
Slug: slug,
|
Slug: f.Slug,
|
||||||
Summary: summary,
|
Summary: f.Summary,
|
||||||
Content: content,
|
Content: f.Content,
|
||||||
Cover: cover,
|
Cover: f.Cover,
|
||||||
Status: status,
|
Status: status,
|
||||||
IsTop: isTop,
|
IsTop: f.IsTop,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&article).Error; err != nil {
|
if err := db.Create(&article).Error; err != nil {
|
||||||
data := DefaultData(c)
|
renderArticleForm(c, db, f, tr["article_error"])
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,3 +182,111 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.Redirect(http.StatusFound, "/admin")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,21 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_content_required": "Content is required.",
|
"article_content_required": "Content is required.",
|
||||||
"article_back_home": "Back to Home",
|
"article_back_home": "Back to Home",
|
||||||
"article_not_found": "Article not found.",
|
"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: {
|
ZH: {
|
||||||
// 导航
|
// 导航
|
||||||
@@ -221,6 +236,21 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_content_required": "正文不能为空。",
|
"article_content_required": "正文不能为空。",
|
||||||
"article_back_home": "返回首页",
|
"article_back_home": "返回首页",
|
||||||
"article_not_found": "文章不存在。",
|
"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": "操作",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ func main() {
|
|||||||
admin.Use(middleware.AuthRequired())
|
admin.Use(middleware.AuthRequired())
|
||||||
{
|
{
|
||||||
admin.GET("", handlers.AdminDashboard(db))
|
admin.GET("", handlers.AdminDashboard(db))
|
||||||
|
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||||
admin.POST("/articles/new", handlers.ArticleCreate(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.
|
// Protected profile routes.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
|
|
||||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "article_create_title"}}</h2>
|
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||||
|
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form action="/admin/articles/new" method="post" class="space-y-6">
|
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
<!-- IsTop -->
|
<!-- IsTop -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox"
|
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "article_list"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "article_list_title"}}</h2>
|
||||||
|
<a href="/admin/articles/new"
|
||||||
|
class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{{index .Tr "dash_new_article"}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_title"}}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_status"}}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_published"}}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "article_col_actions"}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{{range .Articles}}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800">
|
||||||
|
{{.Title}}
|
||||||
|
{{if .IsTop}}<span class="ml-1 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">{{index $.Tr "article_is_top"}}</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
{{if eq .Status 1}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">{{index $.Tr "article_published"}}</span>
|
||||||
|
{{else if eq .Status 2}}<span class="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded">{{index $.Tr "article_archived"}}</span>
|
||||||
|
{{else}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">{{index $.Tr "article_draft"}}</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||||
|
<a href="/admin/articles/{{.ID}}/edit"
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||||
|
<form action="/admin/articles/{{.ID}}/delete" method="post" class="inline"
|
||||||
|
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||||
|
<button type="submit"
|
||||||
|
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-4 py-12 text-center text-gray-500">{{index .Tr "home_no_posts"}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">{{index .Tr "dash_mgmt_title"}}</h3>
|
<h3 class="text-xl font-semibold text-gray-800 mb-2">{{index .Tr "dash_mgmt_title"}}</h3>
|
||||||
<p class="text-gray-500 mb-6">{{index .Tr "dash_mgmt_desc"}}</p>
|
<p class="text-gray-500 mb-6">{{index .Tr "dash_mgmt_desc"}}</p>
|
||||||
|
<div class="flex justify-center gap-3 flex-wrap">
|
||||||
<a href="/admin/articles/new"
|
<a href="/admin/articles/new"
|
||||||
class="inline-flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
class="inline-flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -45,6 +46,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{index .Tr "dash_new_article"}}
|
{{index .Tr "dash_new_article"}}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/articles"
|
||||||
|
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||||
|
{{index .Tr "article_manage"}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||||
{{index .Tr "home_subtitle"}}
|
{{index .Tr "home_subtitle"}}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center gap-4">
|
<!-- <div class="flex justify-center gap-4">
|
||||||
<a href="/login" class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
<a href="/login" class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
{{index .Tr "home_sign_in"}}
|
{{index .Tr "home_sign_in"}}
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin" class="inline-block bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
<a href="/admin" class="inline-block bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||||
{{index .Tr "home_to_dash"}}
|
{{index .Tr "home_to_dash"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div> -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="max-w-5xl mx-auto px-4 py-16">
|
<section class="max-w-5xl mx-auto px-4 py-16">
|
||||||
|
|||||||
Reference in New Issue
Block a user