feat: add platform configuration tables and admin settings
Add four DB-backed configuration tables for site-wide settings: site_settings (logo, top-left title, header/footer text with zh/en variants), upload_configs (master switch, default size, storage dir), upload_file_types (per-extension whitelist with per-type size limits), and download_baseurls (multiple download sources with priority/default). - Models, AutoMigrate, and first-run seed (18 common file types) - Process-level config cache warmed at startup, refreshed on admin save - SetUserContext injects cached site settings into templates - base.html renders logo (local upload or external URL), title, header banner, and footer with i18n fallback - Upload validator drives avatar uploads (form + AJAX) through the configured switch/type/size policy - Admin pages for site/upload/download settings with i18n keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,12 @@ func DefaultData(c *gin.Context) gin.H {
|
||||
avatar, _ := c.Get("avatar")
|
||||
displayName, _ := c.Get("display_name")
|
||||
role, _ := c.Get("role")
|
||||
siteSetting, _ := c.Get("site_setting")
|
||||
siteLogo, _ := c.Get("site_logo")
|
||||
siteLogoIsURL, _ := c.Get("site_logo_is_url")
|
||||
siteLogoText, _ := c.Get("site_logo_text")
|
||||
siteHeaderText, _ := c.Get("site_header_text")
|
||||
siteFooterText, _ := c.Get("site_footer_text")
|
||||
|
||||
return gin.H{
|
||||
"Tr": tr,
|
||||
@@ -26,6 +32,12 @@ func DefaultData(c *gin.Context) gin.H {
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
"SiteSetting": siteSetting,
|
||||
"SiteLogo": siteLogo,
|
||||
"SiteLogoIsURL": siteLogoIsURL,
|
||||
"SiteLogoText": siteLogoText,
|
||||
"SiteHeaderText": siteHeaderText,
|
||||
"SiteFooterText": siteFooterText,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-8
@@ -46,6 +46,14 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
if msg := c.Query("error"); msg == "pw" {
|
||||
data["Error"] = tr["profile_wrong_password"]
|
||||
}
|
||||
switch c.Query("error") {
|
||||
case "upload":
|
||||
data["Error"] = tr["profile_upload_invalid"]
|
||||
case "upload_disabled":
|
||||
data["Error"] = tr["profile_upload_disabled"]
|
||||
case "size":
|
||||
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "profile", data)
|
||||
}
|
||||
@@ -84,11 +92,22 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
|
||||
// Determine file extension.
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
||||
ext = ".jpg"
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
session.Save()
|
||||
reason := "?error=upload"
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
reason = "?error=upload_disabled"
|
||||
} else if check.Type != nil {
|
||||
reason = fmt.Sprintf("?error=size&max=%s", formatSize(check.MaxSize))
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/profile"+reason)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Save under storagePath/avatars/.
|
||||
avatarDir := filepath.Join(storagePath, "avatars")
|
||||
@@ -173,11 +192,23 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension.
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
||||
ext = ".jpg"
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "uploads are disabled"})
|
||||
return
|
||||
}
|
||||
if check.Type != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Read file bytes for image processing.
|
||||
imgBytes, err := io.ReadAll(file)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// mbToBytes converts a megabyte count (string) to bytes. Returns 0 on parse
|
||||
// failure. Values <= 0 are treated as 0 (meaning "use default" for per-type
|
||||
// limits).
|
||||
func mbToBytes(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(n * 1024 * 1024)
|
||||
}
|
||||
|
||||
// bytesToMB renders a byte count as megabytes (one decimal) for form display.
|
||||
func bytesToMB(b int64) string {
|
||||
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
|
||||
}
|
||||
|
||||
// userIDFromSession extracts the logged-in user's ID, or 0 if absent.
|
||||
func userIDFromSession(c *gin.Context) uint {
|
||||
session := sessions.Default(c)
|
||||
if id, ok := session.Get("user_id").(uint); ok {
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------- Site settings ----------------
|
||||
|
||||
// SiteSettingsPage renders the site display settings form.
|
||||
func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_site_title"]
|
||||
data["Site"] = s
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_site", data)
|
||||
}
|
||||
}
|
||||
|
||||
// SiteSettingsSave handles logo upload and text fields for site settings.
|
||||
func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
s.LogoTextZh = strings.TrimSpace(c.PostForm("logo_text_zh"))
|
||||
s.LogoTextEn = strings.TrimSpace(c.PostForm("logo_text_en"))
|
||||
s.HeaderTextZh = strings.TrimSpace(c.PostForm("header_text_zh"))
|
||||
s.HeaderTextEn = strings.TrimSpace(c.PostForm("header_text_en"))
|
||||
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
|
||||
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
|
||||
s.UpdatedBy = userIDFromSession(c)
|
||||
|
||||
// Logo upload (optional). A logo_url form field takes precedence over an
|
||||
// uploaded file, so admins can set either a local file or an external link.
|
||||
if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" {
|
||||
s.Logo = logoURL
|
||||
} else if file, header, err := c.Request.FormFile("logo"); err == nil {
|
||||
defer file.Close()
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK || check.Type.Category != models.CategoryImage {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
logoDir := filepath.Join(storagePath, "logos")
|
||||
os.MkdirAll(logoDir, 0755)
|
||||
// Remove the previous local logo (skip external URLs).
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(logoDir, s.Logo))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("logo%s", ext)
|
||||
dst, err := os.Create(filepath.Join(logoDir, savedName))
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
s.Logo = savedName
|
||||
}
|
||||
|
||||
// Remove logo entirely if requested.
|
||||
if c.PostForm("logo_clear") == "1" {
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Logo))
|
||||
}
|
||||
s.Logo = ""
|
||||
}
|
||||
|
||||
if err := db.Save(&s).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload settings ----------------
|
||||
|
||||
// fileTypeView augments an UploadFileType with a pre-rendered max-size MB
|
||||
// string for the template (avoids needing a template FuncMap for division).
|
||||
type fileTypeView struct {
|
||||
models.UploadFileType
|
||||
MaxSizeMB string
|
||||
}
|
||||
|
||||
// UploadSettingsPage renders the upload policy + file-type management page.
|
||||
func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: models.DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||
}
|
||||
var types []models.UploadFileType
|
||||
db.Order("category asc, sort asc, id asc").Find(&types)
|
||||
|
||||
views := make([]fileTypeView, 0, len(types))
|
||||
for _, t := range types {
|
||||
views = append(views, fileTypeView{UploadFileType: t, MaxSizeMB: bytesToMB(t.MaxSize)})
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_upload_title"]
|
||||
data["Upload"] = u
|
||||
data["FileTypes"] = views
|
||||
data["DefaultMaxSizeMB"] = bytesToMB(u.DefaultMaxSize)
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_upload", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadSettingsSave dispatches upload-config and file-type actions.
|
||||
func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
case "save_config":
|
||||
saveUploadConfig(db, c)
|
||||
case "add_type":
|
||||
addUploadFileType(db, c)
|
||||
case "toggle_type":
|
||||
toggleUploadFileType(db, c)
|
||||
case "size_type":
|
||||
sizeUploadFileType(db, c)
|
||||
case "delete_type":
|
||||
deleteUploadFileType(db, c)
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/upload?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1}
|
||||
}
|
||||
u.Enabled = c.PostForm("enabled") == "1"
|
||||
u.DefaultMaxSize = mbToBytes(c.PostForm("default_max_size"))
|
||||
if u.DefaultMaxSize <= 0 {
|
||||
u.DefaultMaxSize = models.DefaultUploadMaxSize
|
||||
}
|
||||
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
|
||||
u.StorageDir = dir
|
||||
}
|
||||
u.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&u)
|
||||
}
|
||||
|
||||
func addUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension")))
|
||||
if ext == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
t := models.UploadFileType{
|
||||
Extension: ext,
|
||||
MimeType: strings.TrimSpace(c.PostForm("mime_type")),
|
||||
Category: strings.TrimSpace(c.PostForm("category")),
|
||||
MaxSize: mbToBytes(c.PostForm("max_size")),
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
Sort: 50,
|
||||
}
|
||||
if t.Category == "" {
|
||||
t.Category = models.CategoryOther
|
||||
}
|
||||
// Ignore duplicate-extension errors silently.
|
||||
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
|
||||
}
|
||||
|
||||
func toggleUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
return
|
||||
}
|
||||
t.Enabled = !t.Enabled
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func sizeUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
return
|
||||
}
|
||||
t.MaxSize = mbToBytes(c.PostForm("max_size"))
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func deleteUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.UploadFileType{}, id)
|
||||
}
|
||||
|
||||
// ---------------- Download settings ----------------
|
||||
|
||||
// DownloadSettingsPage renders the download base-URL management page.
|
||||
func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var urls []models.DownloadBaseURL
|
||||
db.Order("is_default desc, priority asc, id asc").Find(&urls)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_download_title"]
|
||||
data["BaseURLs"] = urls
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_download", data)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadSettingsSave dispatches download base-URL actions.
|
||||
func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
case "add":
|
||||
addDownloadBaseURL(db, c)
|
||||
case "toggle":
|
||||
toggleDownloadBaseURL(db, c)
|
||||
case "default":
|
||||
defaultDownloadBaseURL(db, c)
|
||||
case "delete":
|
||||
deleteDownloadBaseURL(db, c)
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/download?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
func addDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
base := strings.TrimSpace(c.PostForm("base_url"))
|
||||
if base == "" {
|
||||
return
|
||||
}
|
||||
prio, _ := strconv.Atoi(c.PostForm("priority"))
|
||||
b := models.DownloadBaseURL{
|
||||
Name: name,
|
||||
BaseURL: base,
|
||||
Priority: prio,
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
}
|
||||
db.Create(&b)
|
||||
}
|
||||
|
||||
func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var b models.DownloadBaseURL
|
||||
if db.First(&b, id).Error != nil {
|
||||
return
|
||||
}
|
||||
b.Enabled = !b.Enabled
|
||||
db.Save(&b)
|
||||
}
|
||||
|
||||
func defaultDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
// Only one default at a time.
|
||||
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
|
||||
db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true)
|
||||
}
|
||||
|
||||
func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.DownloadBaseURL{}, id)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// ErrUploadsDisabled is returned when the global upload switch is off.
|
||||
var ErrUploadsDisabled = errors.New("uploads are disabled")
|
||||
|
||||
// FileValidationError describes why an uploaded file was rejected.
|
||||
type FileValidationError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *FileValidationError) Error() string { return e.Reason }
|
||||
|
||||
// FileCheck is the outcome of validating an uploaded file header.
|
||||
type FileCheck struct {
|
||||
OK bool
|
||||
Type *models.UploadFileType // matched type, nil if not found
|
||||
MaxSize int64 // effective byte limit applied
|
||||
}
|
||||
|
||||
// ValidateUpload checks a file header against the cached platform upload
|
||||
// policy: master switch, extension whitelist, and per-type size limit. The
|
||||
// reported MaxSize is the effective limit (per-type override, else default).
|
||||
func ValidateUpload(header *multipart.FileHeader) FileCheck {
|
||||
cfg := models.GetUploadConfig()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return FileCheck{OK: false}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
def := cfg.DefaultMaxSize
|
||||
for i := range models.GetUploadFileTypes() {
|
||||
t := &models.GetUploadFileTypes()[i]
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(t.Extension, ext) {
|
||||
max := t.EffectiveMaxSize(def)
|
||||
if header.Size > max {
|
||||
return FileCheck{OK: false, Type: t, MaxSize: max}
|
||||
}
|
||||
return FileCheck{OK: true, Type: t, MaxSize: max}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension not in the whitelist.
|
||||
return FileCheck{OK: false, MaxSize: def}
|
||||
}
|
||||
|
||||
// formatSize renders a byte count as a human-readable string.
|
||||
func formatSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
+100
@@ -85,6 +85,9 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "Save Changes",
|
||||
"profile_saved": "Profile updated.",
|
||||
"profile_wrong_password": "Current password is incorrect.",
|
||||
"profile_upload_invalid": "File type not allowed.",
|
||||
"profile_upload_disabled": "Uploads are currently disabled.",
|
||||
"profile_upload_too_large": "File is too large. Limit: %s",
|
||||
"gender_male": "Male",
|
||||
"gender_female": "Female",
|
||||
"gender_other": "Other",
|
||||
@@ -133,6 +136,53 @@ var translations = map[Lang]map[string]string{
|
||||
"article_col_status": "Status",
|
||||
"article_col_published": "Published",
|
||||
"article_col_actions": "Actions",
|
||||
|
||||
// Settings (platform configuration)
|
||||
"settings_nav": "Platform Settings",
|
||||
"settings_saved": "Settings saved.",
|
||||
"settings_site_title": "Site Settings",
|
||||
"settings_site_desc": "Logo, top-left title, header banner, and footer text.",
|
||||
"settings_logo": "Logo",
|
||||
"settings_logo_url": "Logo URL (external link)",
|
||||
"settings_logo_upload": "Or upload a logo image",
|
||||
"settings_logo_clear": "Remove current logo",
|
||||
"settings_logo_text_zh": "Site Title (Chinese)",
|
||||
"settings_logo_text_en": "Site Title (English)",
|
||||
"settings_header_text_zh": "Header Banner Text (Chinese)",
|
||||
"settings_header_text_en": "Header Banner Text (English)",
|
||||
"settings_footer_text_zh": "Footer Text (Chinese)",
|
||||
"settings_footer_text_en": "Footer Text (English)",
|
||||
"settings_leave_blank": "Leave blank to use the built-in default.",
|
||||
"settings_save": "Save",
|
||||
"settings_upload_title": "Upload Settings",
|
||||
"settings_upload_desc": "Attachment upload policy and permitted file types.",
|
||||
"settings_uploads_enabled":"Enable attachments",
|
||||
"settings_default_size": "Default max size (MB)",
|
||||
"settings_storage_dir": "Storage sub-directory",
|
||||
"settings_file_types": "Permitted File Types",
|
||||
"settings_add_type": "Add File Type",
|
||||
"settings_extension": "Extension",
|
||||
"settings_mime": "MIME Type",
|
||||
"settings_category": "Category",
|
||||
"settings_max_size": "Max Size (MB)",
|
||||
"settings_enabled": "Enabled",
|
||||
"settings_actions": "Actions",
|
||||
"settings_toggle": "Toggle",
|
||||
"settings_delete": "Delete",
|
||||
"settings_update": "Update",
|
||||
"settings_download_title": "Download Settings",
|
||||
"settings_download_desc": "Base URLs used to build attachment download links.",
|
||||
"settings_add_url": "Add Base URL",
|
||||
"settings_url_name": "Name",
|
||||
"settings_base_url": "Base URL",
|
||||
"settings_priority": "Priority",
|
||||
"settings_is_default": "Default",
|
||||
"settings_set_default": "Set Default",
|
||||
"cat_image": "Image",
|
||||
"cat_document": "Document",
|
||||
"cat_archive": "Archive",
|
||||
"cat_video": "Video",
|
||||
"cat_other": "Other",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -203,6 +253,9 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "保存修改",
|
||||
"profile_saved": "个人信息已更新。",
|
||||
"profile_wrong_password": "当前密码错误。",
|
||||
"profile_upload_invalid": "不允许的文件类型。",
|
||||
"profile_upload_disabled": "上传功能已关闭。",
|
||||
"profile_upload_too_large": "文件过大。限制:%s",
|
||||
"gender_male": "男",
|
||||
"gender_female": "女",
|
||||
"gender_other": "其他",
|
||||
@@ -251,6 +304,53 @@ var translations = map[Lang]map[string]string{
|
||||
"article_col_status": "状态",
|
||||
"article_col_published": "发布时间",
|
||||
"article_col_actions": "操作",
|
||||
|
||||
// 平台设置
|
||||
"settings_nav": "平台设置",
|
||||
"settings_saved": "设置已保存。",
|
||||
"settings_site_title": "站点设置",
|
||||
"settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。",
|
||||
"settings_logo": "Logo",
|
||||
"settings_logo_url": "Logo 链接(外链地址)",
|
||||
"settings_logo_upload": "或上传 Logo 图片",
|
||||
"settings_logo_clear": "移除当前 Logo",
|
||||
"settings_logo_text_zh": "站点标题(中文)",
|
||||
"settings_logo_text_en": "站点标题(英文)",
|
||||
"settings_header_text_zh": "顶部横幅文本(中文)",
|
||||
"settings_header_text_en": "顶部横幅文本(英文)",
|
||||
"settings_footer_text_zh": "页脚文本(中文)",
|
||||
"settings_footer_text_en": "页脚文本(英文)",
|
||||
"settings_leave_blank": "留空则使用内置默认值。",
|
||||
"settings_save": "保存",
|
||||
"settings_upload_title": "上传设置",
|
||||
"settings_upload_desc": "附件上传策略与允许的文件类型。",
|
||||
"settings_uploads_enabled":"启用附件上传",
|
||||
"settings_default_size": "默认最大大小(MB)",
|
||||
"settings_storage_dir": "存储子目录",
|
||||
"settings_file_types": "允许的文件类型",
|
||||
"settings_add_type": "添加文件类型",
|
||||
"settings_extension": "扩展名",
|
||||
"settings_mime": "MIME 类型",
|
||||
"settings_category": "分组",
|
||||
"settings_max_size": "最大大小(MB)",
|
||||
"settings_enabled": "启用",
|
||||
"settings_actions": "操作",
|
||||
"settings_toggle": "切换",
|
||||
"settings_delete": "删除",
|
||||
"settings_update": "更新",
|
||||
"settings_download_title": "下载设置",
|
||||
"settings_download_desc": "用于拼接附件下载链接的基础地址。",
|
||||
"settings_add_url": "添加基础地址",
|
||||
"settings_url_name": "名称",
|
||||
"settings_base_url": "基础地址",
|
||||
"settings_priority": "优先级",
|
||||
"settings_is_default": "默认",
|
||||
"settings_set_default": "设为默认",
|
||||
"cat_image": "图片",
|
||||
"cat_document": "文档",
|
||||
"cat_archive": "压缩包",
|
||||
"cat_video": "视频",
|
||||
"cat_other": "其他",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ func main() {
|
||||
// 2. Initialize the database (auto-migrates, seeds admin).
|
||||
db := models.InitDB(cfg)
|
||||
|
||||
// 2b. Warm the platform configuration cache from the database.
|
||||
models.LoadConfigCache(db)
|
||||
|
||||
// 3. Create session store (cookie-based).
|
||||
store := cookie.NewStore([]byte(cfg.Secret))
|
||||
store.Options(sessions.Options{
|
||||
@@ -65,6 +68,18 @@ func main() {
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
}
|
||||
|
||||
// Protected admin settings routes (platform configuration).
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired())
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
}
|
||||
|
||||
// Protected profile routes.
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired())
|
||||
|
||||
@@ -95,6 +95,16 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
c.Set("avatar", avatar)
|
||||
c.Set("display_name", displayName)
|
||||
c.Set("role", role)
|
||||
|
||||
// --- Site platform configuration (from DB cache) ---
|
||||
site := models.GetSiteSetting()
|
||||
c.Set("site_setting", site)
|
||||
c.Set("site_logo", site.Logo)
|
||||
c.Set("site_logo_is_url", site.LogoIsURL())
|
||||
c.Set("site_logo_text", site.LogoText(string(lang)))
|
||||
c.Set("site_header_text", site.HeaderText(string(lang)))
|
||||
c.Set("site_footer_text", site.FooterText(string(lang)))
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// configCache holds process-level caches of platform configuration so that
|
||||
// per-request rendering and upload validation do not hit the database. The
|
||||
// cache is populated once at startup and refreshed whenever an admin saves a
|
||||
// settings page (see RefreshConfigCache / the admin handlers).
|
||||
var configCache = struct {
|
||||
mu sync.RWMutex
|
||||
site *SiteSetting
|
||||
upload *UploadConfig
|
||||
types []UploadFileType
|
||||
baseURLs []DownloadBaseURL
|
||||
}{
|
||||
site: &SiteSetting{},
|
||||
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
||||
}
|
||||
|
||||
// LoadConfigCache reads all platform configuration from the database into the
|
||||
// process cache. Called once at startup after InitDB.
|
||||
func LoadConfigCache(db *gorm.DB) {
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
|
||||
var s SiteSetting
|
||||
if err := db.First(&s, 1).Error; err == nil {
|
||||
configCache.site = &s
|
||||
} else {
|
||||
configCache.site = &SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
var u UploadConfig
|
||||
if err := db.First(&u, 1).Error; err == nil {
|
||||
configCache.upload = &u
|
||||
} else {
|
||||
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||
}
|
||||
|
||||
var t []UploadFileType
|
||||
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
|
||||
configCache.types = t
|
||||
}
|
||||
|
||||
var b []DownloadBaseURL
|
||||
if err := db.Order("is_default desc, priority asc, id asc").Find(&b).Error; err == nil {
|
||||
configCache.baseURLs = b
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
||||
// call this after writing changes so the next request sees them.
|
||||
func RefreshConfigCache(db *gorm.DB) {
|
||||
LoadConfigCache(db)
|
||||
}
|
||||
|
||||
// GetSiteSetting returns a pointer to the cached site settings (read-only copy
|
||||
// semantics: callers must not mutate).
|
||||
func GetSiteSetting() *SiteSetting {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.site
|
||||
}
|
||||
|
||||
// GetUploadConfig returns the cached upload policy.
|
||||
func GetUploadConfig() *UploadConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.upload
|
||||
}
|
||||
|
||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||
func GetUploadFileTypes() []UploadFileType {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.types
|
||||
}
|
||||
|
||||
// GetDownloadBaseURLs returns the cached list of download base URLs.
|
||||
func GetDownloadBaseURLs() []DownloadBaseURL {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.baseURLs
|
||||
}
|
||||
|
||||
// DefaultDownloadBaseURL returns the base URL used to build attachment download
|
||||
// links: the enabled row marked IsDefault, else the highest-priority enabled
|
||||
// row. Returns an empty string if none is configured.
|
||||
func DefaultDownloadBaseURL() string {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
// Rows are ordered is_default desc, priority asc, so the first enabled row
|
||||
// is the right pick.
|
||||
for _, b := range configCache.baseURLs {
|
||||
if b.Enabled {
|
||||
return b.BaseURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+6
-1
@@ -45,10 +45,15 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
if err := db.AutoMigrate(&User{}, &Article{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedSiteSettings inserts the singleton site_settings row (id=1) if absent.
|
||||
// Text fields are left empty so templates fall back to i18n defaults until an
|
||||
// admin configures them.
|
||||
func seedSiteSettings(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&SiteSetting{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
s := &SiteSetting{ID: 1}
|
||||
if err := db.Create(s).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed site_settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent.
|
||||
func seedUploadConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadConfig{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
c := &UploadConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
DefaultMaxSize: DefaultUploadMaxSize,
|
||||
StorageDir: "attachments",
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed upload_configs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||
// seeded on first run.
|
||||
var defaultUploadFileTypes = []UploadFileType{
|
||||
// Images
|
||||
{Extension: ".jpg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 1},
|
||||
{Extension: ".jpeg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 2},
|
||||
{Extension: ".png", MimeType: "image/png", Category: CategoryImage, Enabled: true, Sort: 3},
|
||||
{Extension: ".gif", MimeType: "image/gif", Category: CategoryImage, Enabled: true, Sort: 4},
|
||||
{Extension: ".webp", MimeType: "image/webp", Category: CategoryImage, Enabled: true, Sort: 5},
|
||||
// Documents
|
||||
{Extension: ".pdf", MimeType: "application/pdf", Category: CategoryDocument, Enabled: true, Sort: 10},
|
||||
{Extension: ".doc", MimeType: "application/msword", Category: CategoryDocument, Enabled: true, Sort: 11},
|
||||
{Extension: ".docx", MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Category: CategoryDocument, Enabled: true, Sort: 12},
|
||||
{Extension: ".xls", MimeType: "application/vnd.ms-excel", Category: CategoryDocument, Enabled: true, Sort: 13},
|
||||
{Extension: ".xlsx", MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Category: CategoryDocument, Enabled: true, Sort: 14},
|
||||
{Extension: ".ppt", MimeType: "application/vnd.ms-powerpoint", Category: CategoryDocument, Enabled: true, Sort: 15},
|
||||
{Extension: ".pptx", MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", Category: CategoryDocument, Enabled: true, Sort: 16},
|
||||
{Extension: ".txt", MimeType: "text/plain", Category: CategoryDocument, Enabled: true, Sort: 17},
|
||||
// Archives
|
||||
{Extension: ".zip", MimeType: "application/zip", Category: CategoryArchive, Enabled: true, Sort: 20},
|
||||
{Extension: ".rar", MimeType: "application/vnd.rar", Category: CategoryArchive, Enabled: true, Sort: 21},
|
||||
{Extension: ".7z", MimeType: "application/x-7z-compressed", Category: CategoryArchive, Enabled: true, Sort: 22},
|
||||
// Video
|
||||
{Extension: ".mp4", MimeType: "video/mp4", Category: CategoryVideo, Enabled: true, Sort: 30},
|
||||
{Extension: ".avi", MimeType: "video/x-msvideo", Category: CategoryVideo, Enabled: true, Sort: 31},
|
||||
}
|
||||
|
||||
// seedUploadFileTypes seeds the permitted file-type rows if the table is empty.
|
||||
func seedUploadFileTypes(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadFileType{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
if err := db.Create(&defaultUploadFileTypes).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed upload_file_types: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SiteSetting holds the singleton (id=1) global display configuration:
|
||||
// logo, top-left title, header banner text, and footer text, each with
|
||||
// zh/en variants that fall back to i18n defaults when empty.
|
||||
type SiteSetting struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (SiteSetting) TableName() string {
|
||||
return "site_settings"
|
||||
}
|
||||
|
||||
// LogoIsURL reports whether the logo value is an external URL rather than a
|
||||
// local filename.
|
||||
func (s *SiteSetting) LogoIsURL() bool {
|
||||
if s == nil || s.Logo == "" {
|
||||
return false
|
||||
}
|
||||
return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
|
||||
}
|
||||
|
||||
// LogoText returns the title for the given language code, falling back to the
|
||||
// other language when the requested one is empty.
|
||||
func (s *SiteSetting) LogoText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.LogoTextZh != "" {
|
||||
return s.LogoTextZh
|
||||
}
|
||||
return s.LogoTextEn
|
||||
}
|
||||
if s.LogoTextEn != "" {
|
||||
return s.LogoTextEn
|
||||
}
|
||||
return s.LogoTextZh
|
||||
}
|
||||
|
||||
// HeaderText returns the header banner text for the given language code,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) HeaderText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HeaderTextZh != "" {
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
return s.HeaderTextEn
|
||||
}
|
||||
if s.HeaderTextEn != "" {
|
||||
return s.HeaderTextEn
|
||||
}
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
|
||||
// FooterText returns the footer text for the given language code, falling
|
||||
// back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) FooterText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.FooterTextZh != "" {
|
||||
return s.FooterTextZh
|
||||
}
|
||||
return s.FooterTextEn
|
||||
}
|
||||
if s.FooterTextEn != "" {
|
||||
return s.FooterTextEn
|
||||
}
|
||||
return s.FooterTextZh
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadCategory groups file types for the admin UI.
|
||||
const (
|
||||
CategoryImage = "image"
|
||||
CategoryDocument = "document"
|
||||
CategoryArchive = "archive"
|
||||
CategoryVideo = "video"
|
||||
CategoryOther = "other"
|
||||
)
|
||||
|
||||
// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes.
|
||||
const DefaultUploadMaxSize int64 = 10 * 1024 * 1024
|
||||
|
||||
// UploadConfig holds the singleton (id=1) global attachment upload policy.
|
||||
type UploadConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for attachment uploads
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // bytes; overridden per type by UploadFileType.MaxSize
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // sub-dir under cfg.Path
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (UploadConfig) TableName() string {
|
||||
return "upload_configs"
|
||||
}
|
||||
|
||||
// UploadFileType describes one permitted attachment extension. Multiple rows.
|
||||
type UploadFileType struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // with leading dot, e.g. ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // associated MIME for validation
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // bytes; 0 means use UploadConfig.DefaultMaxSize
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (UploadFileType) TableName() string {
|
||||
return "upload_file_types"
|
||||
}
|
||||
|
||||
// EffectiveMaxSize returns the per-file size limit for this type, falling back
|
||||
// to the provided default when MaxSize is 0.
|
||||
func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
if t.MaxSize > 0 {
|
||||
return t.MaxSize
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// DownloadBaseURL is one source base URL used to build attachment download
|
||||
// links. Multiple rows; the row marked IsDefault (or the highest-priority
|
||||
// enabled one) is used for generated links.
|
||||
type DownloadBaseURL struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64" json:"name"` // label, e.g. "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // e.g. https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // lower = higher priority
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (DownloadBaseURL) TableName() string {
|
||||
return "download_baseurls"
|
||||
}
|
||||
@@ -50,6 +50,10 @@
|
||||
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>
|
||||
<a href="/admin/settings/site"
|
||||
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 "settings_nav"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{{define "settings_download"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_download_title"}}</h2>
|
||||
<p class="text-gray-500 mb-4">{{index .Tr "settings_download_desc"}}</p>
|
||||
|
||||
<div class="flex gap-2 mb-8">
|
||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_download_title"}}</a>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
|
||||
<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 "settings_url_name"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_base_url"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_priority"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_is_default"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_enabled"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .BaseURLs}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 break-all">{{.BaseURL}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.Priority}}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{{if .IsDefault}}<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">★</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{{if .Enabled}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">✓</span>
|
||||
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<form action="/admin/settings/download" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="default">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/download" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/download" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-12 text-center text-gray-500">—</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add base URL -->
|
||||
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_url_name"}}</label>
|
||||
<input type="text" name="name" placeholder="CDN"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_base_url"}}</label>
|
||||
<input type="text" name="base_url" placeholder="https://cdn.example.com/uploads" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_priority"}}</label>
|
||||
<input type="number" name="priority" value="0"
|
||||
class="w-24 border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-5">
|
||||
<input type="checkbox" name="enabled" value="1" checked> {{index .Tr "settings_enabled"}}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "settings_add_url"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "settings_site"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_site_title"}}</h2>
|
||||
<p class="text-gray-500 mb-4">{{index .Tr "settings_site_desc"}}</p>
|
||||
|
||||
<div class="flex gap-2 mb-8">
|
||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_site_title"}}</a>
|
||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<!-- Logo -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
|
||||
{{if .Site.Logo}}
|
||||
{{if .Site.LogoIsURL}}
|
||||
<img src="{{.Site.Logo}}" alt="logo" class="h-12 w-auto mb-2">
|
||||
{{else}}
|
||||
<img src="/uploads/logos/{{.Site.Logo}}" alt="logo" class="h-12 w-auto mb-2">
|
||||
{{end}}
|
||||
{{end}}
|
||||
<input type="text" name="logo_url" placeholder="{{index .Tr "settings_logo_url"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .Site.LogoIsURL}}{{.Site.Logo}}{{end}}">
|
||||
<input type="file" name="logo" accept="image/*"
|
||||
class="block w-full text-sm text-gray-500 mb-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<input type="checkbox" name="logo_clear" value="1"> {{index .Tr "settings_logo_clear"}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Title texts -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_logo_text_zh"}}</label>
|
||||
<input type="text" name="logo_text_zh" value="{{.Site.LogoTextZh}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_logo_text_en"}}</label>
|
||||
<input type="text" name="logo_text_en" value="{{.Site.LogoTextEn}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header texts -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_header_text_zh"}}</label>
|
||||
<textarea name="header_text_zh" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HeaderTextZh}}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_header_text_en"}}</label>
|
||||
<textarea name="header_text_en" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HeaderTextEn}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer texts -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_footer_text_zh"}}</label>
|
||||
<textarea name="footer_text_zh" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.FooterTextZh}}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_footer_text_en"}}</label>
|
||||
<textarea name="footer_text_en" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.FooterTextEn}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "settings_save"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,139 @@
|
||||
{{define "settings_upload"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_upload_title"}}</h2>
|
||||
<p class="text-gray-500 mb-4">{{index .Tr "settings_upload_desc"}}</p>
|
||||
|
||||
<div class="flex gap-2 mb-8">
|
||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_upload_title"}}</a>
|
||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Global policy -->
|
||||
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<input type="hidden" name="action" value="save_config">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_default_size"}}</label>
|
||||
<input type="number" name="default_max_size" min="0" step="0.1" value="{{.DefaultMaxSizeMB}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_storage_dir"}}</label>
|
||||
<input type="text" name="storage_dir" value="{{.Upload.StorageDir}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" name="enabled" value="1" {{if .Upload.Enabled}}checked{{end}}>
|
||||
{{index .Tr "settings_uploads_enabled"}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "settings_save"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- File types table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="font-semibold text-gray-800">{{index .Tr "settings_file_types"}}</h3>
|
||||
</div>
|
||||
<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 "settings_extension"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_mime"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_category"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_max_size"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_enabled"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .FileTypes}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-mono">{{.Extension}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.MimeType}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
|
||||
<input type="hidden" name="action" value="size_type">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
|
||||
class="w-20 border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<button type="submit" class="text-blue-600 hover:text-blue-800 text-xs">{{index $.Tr "settings_update"}}</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{{if .Enabled}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">✓</span>
|
||||
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<form action="/admin/settings/upload" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle_type">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/upload" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<input type="hidden" name="action" value="delete_type">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add file type -->
|
||||
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<input type="hidden" name="action" value="add_type">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_extension"}}</label>
|
||||
<input type="text" name="extension" placeholder=".pdf" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_mime"}}</label>
|
||||
<input type="text" name="mime_type" placeholder="application/pdf"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_category"}}</label>
|
||||
<select name="category" class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<option value="image">{{index .Tr "cat_image"}}</option>
|
||||
<option value="document">{{index .Tr "cat_document"}}</option>
|
||||
<option value="archive">{{index .Tr "cat_archive"}}</option>
|
||||
<option value="video">{{index .Tr "cat_video"}}</option>
|
||||
<option value="other">{{index .Tr "cat_other"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_max_size"}} (0=default)</label>
|
||||
<input type="number" name="max_size" min="0" step="0.1" value="0"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mb-4">
|
||||
<input type="checkbox" name="enabled" value="1" checked> {{index .Tr "settings_enabled"}}
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="block bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "settings_add_type"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -15,8 +15,15 @@
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors">
|
||||
{{index .Tr "site_title"}}
|
||||
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
{{if .SiteLogo}}
|
||||
{{if .SiteLogoIsURL}}
|
||||
<img src="{{.SiteLogo}}" alt="logo" class="h-8 w-auto">
|
||||
{{else}}
|
||||
<img src="/uploads/logos/{{.SiteLogo}}" alt="logo" class="h-8 w-auto">
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "home"}}</a>
|
||||
@@ -62,6 +69,12 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
{{if .SiteHeaderText}}
|
||||
<!-- Header banner -->
|
||||
<div class="bg-blue-50 border-b border-blue-100 text-blue-800 text-sm text-center py-2 px-4">
|
||||
{{.SiteHeaderText}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
@@ -70,7 +83,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-gray-200 py-6 mt-auto">
|
||||
<div class="max-w-5xl mx-auto px-4 text-center text-gray-500 text-sm">
|
||||
{{index .Tr "footer_text"}}
|
||||
{{if .SiteFooterText}}{{.SiteFooterText}}{{else}}{{index .Tr "footer_text"}}{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user