feat: add navigation links management and user registration

Features:
- Custom navigation links management in admin settings
- Multi-language support for navigation links (Chinese/English)
- Control link behavior (open in new window)
- Sort order and enable/disable toggle for nav links
- User registration system with validation
- Admin can enable/disable user registration
- Registration form with username, display name, email, password
- Link to registration from login page

Navigation Links:
- Admin interface to add/edit/delete custom nav links
- Support for both internal and external URLs
- Display links in header navigation bar
- Respects language preference

User Registration:
- Username validation (3-32 characters, alphanumeric + underscore/dash)
- Password validation (minimum 6 characters)
- Password confirmation matching
- Optional display name and email fields
- Can be toggled on/off by admin in site settings

Templates:
- Added navigation links settings page
- Added user registration page
- Updated login page with registration link
- Updated base layout to render custom nav links

Documentation:
- NAV_LINKS_FEATURE.md - Navigation links feature documentation
- REGISTRATION_FEATURE.md - User registration documentation
- IMPLEMENTATION_SUMMARY.md - Overall implementation summary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:01:38 +08:00
co-authored by Claude Fable 5
parent 2bdc368399
commit d9bb91af00
18 changed files with 1224 additions and 15 deletions
+109
View File
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -19,6 +20,11 @@ func LoginPage() gin.HandlerFunc {
if c.Query("error") == "1" {
data["Error"] = tr["login_error"]
}
// Check if registration is allowed from site settings
siteSetting, _ := c.Get("site_setting")
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
data["AllowRegistration"] = s.AllowRegistration
}
c.HTML(http.StatusOK, "login", data)
}
}
@@ -73,3 +79,106 @@ func Logout() gin.HandlerFunc {
c.Redirect(http.StatusFound, "/")
}
}
// RegisterPage renders the registration form (only when registration is enabled).
func RegisterPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Check if registration is allowed
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
c.Redirect(http.StatusFound, "/login")
return
}
tr := getTr(c)
data := DefaultData(c)
data["Title"] = tr["page_register"]
if errMsg := c.Query("error"); errMsg != "" {
data["Error"] = tr[errMsg]
}
c.HTML(http.StatusOK, "register", data)
}
}
// Register processes the registration form submission.
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Check if registration is allowed
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
c.Redirect(http.StatusFound, "/login")
return
}
username := strings.TrimSpace(c.PostForm("username"))
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm_password")
email := strings.TrimSpace(c.PostForm("email"))
displayName := strings.TrimSpace(c.PostForm("display_name"))
// Validate inputs
if username == "" || password == "" {
c.Redirect(http.StatusFound, "/register?error=register_required")
return
}
if len(username) < 3 || len(username) > 32 {
c.Redirect(http.StatusFound, "/register?error=register_username_length")
return
}
if len(password) < 6 {
c.Redirect(http.StatusFound, "/register?error=register_password_length")
return
}
if password != confirmPassword {
c.Redirect(http.StatusFound, "/register?error=register_password_mismatch")
return
}
// Check if username already exists
var existingUser models.User
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
c.Redirect(http.StatusFound, "/register?error=user_username_exists")
return
}
// Create new user
user := models.User{
Username: username,
Email: email,
DisplayName: displayName,
Role: models.RoleAuthor,
Status: models.StatusNormal,
}
if displayName == "" {
user.DisplayName = username
}
if err := user.SetPassword(password); err != nil {
c.Redirect(http.StatusFound, "/register?error=register_error")
return
}
if err := db.Create(&user).Error; err != nil {
c.Redirect(http.StatusFound, "/register?error=register_error")
return
}
// Auto-login after successful registration
session := sessions.Default(c)
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
c.Redirect(http.StatusFound, "/login")
return
}
// Redirect to home page
c.Redirect(http.StatusFound, "/")
}
}
+2
View File
@@ -26,6 +26,7 @@ func DefaultData(c *gin.Context) gin.H {
siteHomeWelcome, _ := c.Get("site_home_welcome")
siteHomeSubtitle, _ := c.Get("site_home_subtitle")
siteFooterText, _ := c.Get("site_footer_text")
navLinks, _ := c.Get("nav_links")
return gin.H{
"Tr": tr,
@@ -46,6 +47,7 @@ func DefaultData(c *gin.Context) gin.H {
"SiteHomeWelcome": siteHomeWelcome,
"SiteHomeSubtitle": siteHomeSubtitle,
"SiteFooterText": siteFooterText,
"NavLinks": navLinks,
}
}
+102
View File
@@ -100,6 +100,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc {
s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en"))
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
s.AllowRegistration = c.PostForm("allow_registration") == "1"
s.UpdatedBy = userIDFromSession(c)
// Favicon upload (optional). A favicon_url form field takes precedence over an
@@ -427,3 +428,104 @@ func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
}
}
// ---------------- Navigation Links settings ----------------
// NavLinksSettingsPage renders the navigation links management page.
func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var links []models.NavLink
db.Order("sort asc, id asc").Find(&links)
data := DefaultData(c)
data["Title"] = tr["settings_navlinks_title"]
data["NavLinks"] = links
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
c.HTML(http.StatusOK, "settings_navlinks", data)
}
}
// NavLinksSettingsSave dispatches navigation link actions.
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
switch c.PostForm("action") {
case "add":
addNavLink(db, c)
case "toggle":
toggleNavLink(db, c)
case "edit":
editNavLink(db, c)
case "delete":
deleteNavLink(db, c)
}
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1")
}
}
func addNavLink(db *gorm.DB, c *gin.Context) {
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
titleEn := strings.TrimSpace(c.PostForm("title_en"))
url := strings.TrimSpace(c.PostForm("url"))
if url == "" || (titleZh == "" && titleEn == "") {
return
}
sort, _ := strconv.Atoi(c.PostForm("sort"))
link := models.NavLink{
TitleZh: titleZh,
TitleEn: titleEn,
URL: url,
OpenNew: c.PostForm("open_new") == "1",
Enabled: c.PostForm("enabled") != "0",
Sort: sort,
UpdatedBy: userIDFromSession(c),
}
db.Create(&link)
}
func toggleNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var link models.NavLink
if db.First(&link, id).Error != nil {
return
}
link.Enabled = !link.Enabled
link.UpdatedBy = userIDFromSession(c)
db.Save(&link)
}
func editNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var link models.NavLink
if db.First(&link, id).Error != nil {
return
}
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
titleEn := strings.TrimSpace(c.PostForm("title_en"))
url := strings.TrimSpace(c.PostForm("url"))
if url == "" || (titleZh == "" && titleEn == "") {
return
}
link.TitleZh = titleZh
link.TitleEn = titleEn
link.URL = url
link.OpenNew = c.PostForm("open_new") == "1"
link.Sort, _ = strconv.Atoi(c.PostForm("sort"))
link.UpdatedBy = userIDFromSession(c)
db.Save(&link)
}
func deleteNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
db.Delete(&models.NavLink{}, id)
}