Compare commits
29
Commits
a84982e1f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9ccdde75 | ||
|
|
2875918289 | ||
|
|
1fc933e014 | ||
|
|
fe69a2e38b | ||
|
|
d8779ed5bf | ||
|
|
a316932d3f | ||
|
|
5f8c7be5ac | ||
|
|
fe72cbaf9d | ||
|
|
d9bb91af00 | ||
|
|
2bdc368399 | ||
|
|
b4983f1d4d | ||
|
|
b0ca76e8dc | ||
|
|
b600eac21c | ||
|
|
da7a39c1c8 | ||
|
|
5ec783e471 | ||
|
|
7047b33358 | ||
|
|
eb9a65097e | ||
|
|
aca4983447 | ||
|
|
95cbd0240f | ||
|
|
d64c839b10 | ||
|
|
cefaeac618 | ||
|
|
6139b3ef2c | ||
|
|
1f1123acf8 | ||
|
|
e872c08f3b | ||
|
|
0c5bcde7da | ||
|
|
e3367e6e12 | ||
|
|
b474ee009a | ||
|
|
42516257e0 | ||
|
|
62732ba348 |
@@ -3,6 +3,9 @@ go_blog
|
||||
go_blog.exe
|
||||
main.exe
|
||||
|
||||
# fresh (live-reload) build output
|
||||
tmp/
|
||||
|
||||
# Auto-generated runtime data (config + database + uploads)
|
||||
win/
|
||||
mac/
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
# Go Blog
|
||||
|
||||
A simple, fast blog engine built with Go, Gin, and Tailwind CSS. Supports SQLite and MySQL, with automatic first-run setup.
|
||||
一个简洁、快速的博客引擎,使用 Go + Gin + Tailwind CSS 构建。支持 SQLite 和 MySQL,首次运行自动完成配置。
|
||||
|
||||
## Features
|
||||
## 功能特性
|
||||
|
||||
- **Multi-language** — 中文 / English, auto-detected or manually switched
|
||||
- **OS-aware config** — Linux `/etc/blog_go/`, Windows `./win/etc/blog_go/`
|
||||
- **First-run setup** — auto-creates config, database, and admin user
|
||||
- **Avatar + Profile** — upload avatar, edit personal info, change password
|
||||
- **Role system** — admin sees management panel; authors see profile only
|
||||
- **Responsive UI** — Tailwind CSS, works on desktop and mobile
|
||||
- **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间
|
||||
- **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置
|
||||
- **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author)
|
||||
- **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章
|
||||
- **个人中心** — 上传头像(支持裁剪)、编辑个人信息、修改密码
|
||||
- **站点设置** — 自定义站点标题、副标题、favicon 图标
|
||||
- **导航管理** — 自定义首页导航链接,支持图标
|
||||
- **瀑布流布局** — 首页文章以瀑布流展示,支持无限滚动加载
|
||||
- **阅读统计** — 文章阅读量统计,带机器人流量检测
|
||||
- **附件上传** — 文章支持上传附件,基于内容寻址自动去重
|
||||
- **RSS 订阅** — 自动生成 RSS Feed(`/rss`、`/feed`)
|
||||
- **搜索功能** — 文章全文搜索
|
||||
- **多语言** — 支持中文 / English,自动检测浏览器语言或手动切换
|
||||
- **自适应界面** — Tailwind CSS,桌面端和移动端均可正常使用
|
||||
- **开箱即用** — 首次运行自动创建配置文件、数据库和管理员账号
|
||||
|
||||
## Tech Stack
|
||||
## 技术栈
|
||||
|
||||
| Layer | Library |
|
||||
| 层级 | 库/工具 |
|
||||
|---|---|
|
||||
| Router | [gin](https://github.com/gin-gonic/gin) |
|
||||
| 路由 | [gin](https://github.com/gin-gonic/gin) |
|
||||
| ORM | [gorm](https://gorm.io) |
|
||||
| SQLite | [glebarez/sqlite](https://github.com/glebarez/sqlite) (pure Go, no CGO) |
|
||||
| Sessions | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
||||
| Config | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
||||
| Passwords | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com) (CDN) |
|
||||
| SQLite | [glebarez/sqlite](https://github.com/glebarez/sqlite)(纯 Go,无需 CGO) |
|
||||
| Session | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
||||
| 配置 | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
||||
| 密码 | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com)(CDN) |
|
||||
|
||||
## Quick Start
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Open http://localhost:8080 and log in with **admin** / **admin**.
|
||||
打开 <http://localhost:8080> ,使用 **admin** / **admin** 登录。
|
||||
|
||||
## Configuration
|
||||
## 配置
|
||||
|
||||
On first run, a config file is created automatically:
|
||||
首次运行时,配置文件会自动生成:
|
||||
|
||||
| Platform | Config Path | Storage Path |
|
||||
| 平台 | 配置文件路径 | 数据存储路径 |
|
||||
|---|---|---|
|
||||
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
|
||||
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
|
||||
@@ -43,16 +52,16 @@ On first run, a config file is created automatically:
|
||||
```yaml
|
||||
# config.yaml
|
||||
database:
|
||||
type: sqlite # sqlite (default) or mysql
|
||||
dsn: "" # MySQL DSN, ignored for sqlite
|
||||
port: "8080" # web server port
|
||||
path: ./win/srv/blog_go # storage path (db, uploads)
|
||||
secret: <auto-generated> # session encryption key
|
||||
type: sqlite # sqlite(默认)或 mysql
|
||||
dsn: "" # MySQL 连接串,sqlite 模式下忽略
|
||||
port: "8080" # Web 服务端口
|
||||
path: ./win/srv/blog_go # 数据存储路径(数据库、上传文件)
|
||||
secret: <自动生成> # Session 加密密钥
|
||||
```
|
||||
|
||||
### MySQL
|
||||
### 使用 MySQL
|
||||
|
||||
Edit `config.yaml`:
|
||||
编辑 `config.yaml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
@@ -61,41 +70,82 @@ database:
|
||||
port: "8080"
|
||||
```
|
||||
|
||||
Create the database first:
|
||||
先创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE blog_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
go_blog/
|
||||
├── main.go # Entry point, routes, middleware wiring
|
||||
├── main.go # 入口,路由注册,中间件装配
|
||||
├── config/
|
||||
│ └── config.go # OS-aware config load / auto-create
|
||||
│ └── config.go # 配置加载 / 自动创建(区分操作系统)
|
||||
├── models/
|
||||
│ ├── user.go # User model + bcrypt
|
||||
│ └── db.go # GORM init, migration, seed
|
||||
│ ├── user.go # 用户模型 + bcrypt
|
||||
│ ├── article.go # 文章模型
|
||||
│ ├── article_tag.go # 文章-标签关联
|
||||
│ ├── article_view.go # 文章阅读统计
|
||||
│ ├── attachment.go # 附件模型
|
||||
│ ├── bot_detector.go # 机器人流量检测
|
||||
│ ├── comment.go # 评论模型
|
||||
│ ├── comment_config.go # 评论配置
|
||||
│ ├── config_cache.go # 平台配置缓存
|
||||
│ ├── db.go # GORM 初始化、自动迁移
|
||||
│ ├── nav_link.go # 导航链接模型
|
||||
│ ├── seed.go # 初始数据填充
|
||||
│ ├── site_setting.go # 站点设置模型
|
||||
│ ├── tag.go # 标签模型
|
||||
│ └── upload_config.go # 上传配置
|
||||
├── middleware/
|
||||
│ └── auth.go # Auth guard, language detection
|
||||
│ └── auth.go # 登录验证、角色鉴权、语言检测
|
||||
├── handlers/
|
||||
│ ├── helpers.go # DefaultData + getTr utilities
|
||||
│ ├── home.go # GET /
|
||||
│ ├── auth.go # GET/POST /login, POST /logout
|
||||
│ ├── admin.go # GET /admin (protected)
|
||||
│ └── profile.go # GET/POST /profile (protected)
|
||||
│ ├── helpers.go # 公共工具函数
|
||||
│ ├── home.go # 首页
|
||||
│ ├── article.go # 文章详情、文章列表 API
|
||||
│ ├── auth.go # 登录
|
||||
│ ├── admin.go # 管理后台首页
|
||||
│ ├── admin_comment.go # 评论管理
|
||||
│ ├── admin_user.go # 用户管理
|
||||
│ ├── admin_analytics.go # 阅读统计
|
||||
│ ├── attachment.go # 附件上传/删除
|
||||
│ ├── comment.go # 评论提交
|
||||
│ ├── my_articles.go # 用户文章管理
|
||||
│ ├── profile.go # 个人中心
|
||||
│ ├── rss.go # RSS Feed
|
||||
│ ├── settings.go # 站点/导航/上传/评论设置
|
||||
│ └── upload_validator.go # 上传文件校验
|
||||
├── i18n/
|
||||
│ └── i18n.go # EN/ZH translation maps + Accept-Language
|
||||
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # Header (nav + avatar dropdown) + footer
|
||||
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||
│ ├── pages/
|
||||
│ │ ├── home.html # Public home page
|
||||
│ │ ├── login.html # Login form
|
||||
│ │ └── profile.html # Edit profile page
|
||||
│ └── admin/
|
||||
│ └── dashboard.html # Admin dashboard
|
||||
└── win/ # Auto-created runtime data (Windows)
|
||||
│ │ ├── home.html # 首页(瀑布流 + 无限滚动)
|
||||
│ │ ├── article.html # 文章详情页
|
||||
│ │ ├── login.html # 登录页
|
||||
│ │ ├── register.html # 注册页
|
||||
│ │ ├── profile.html # 个人中心
|
||||
│ │ ├── search.html # 搜索页
|
||||
│ │ └── 404.html # 404 页面
|
||||
│ ├── admin/
|
||||
│ │ ├── dashboard.html # 管理后台首页
|
||||
│ │ ├── article_list.html # 文章列表
|
||||
│ │ ├── article_create.html# 文章编辑
|
||||
│ │ ├── comment_list.html # 评论审核
|
||||
│ │ ├── user_list.html # 用户列表
|
||||
│ │ ├── user_form.html # 用户表单
|
||||
│ │ ├── analytics_views.html # 阅读统计
|
||||
│ │ ├── settings_site.html # 站点设置
|
||||
│ │ ├── settings_navlinks.html # 导航链接设置
|
||||
│ │ ├── settings_upload.html # 上传设置
|
||||
│ │ ├── settings_download.html # 下载设置
|
||||
│ │ └── settings_comment.html # 评论设置
|
||||
│ └── user/
|
||||
│ ├── my_articles.html # 我的文章列表
|
||||
│ └── my_article_form.html # 我的文章编辑
|
||||
└── win/ # 运行时数据目录(Windows,自动创建)
|
||||
├── etc/blog_go/
|
||||
│ └── config.yaml
|
||||
└── srv/blog_go/
|
||||
@@ -103,26 +153,73 @@ go_blog/
|
||||
└── avatars/
|
||||
```
|
||||
|
||||
## Routes
|
||||
## 路由
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | No | Home page |
|
||||
| GET | `/login` | No | Login form |
|
||||
| POST | `/login` | No | Submit login |
|
||||
| POST | `/logout` | No | Clear session |
|
||||
| GET | `/profile` | Yes | Edit profile |
|
||||
| POST | `/profile` | Yes | Save profile |
|
||||
| GET | `/admin` | Yes | Admin dashboard (admin only) |
|
||||
| GET | `/uploads/*` | No | Static files (avatars, etc.) |
|
||||
### 公开路由
|
||||
|
||||
## User Roles
|
||||
|
||||
| Role | Profile | Admin Dashboard |
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `admin` | ✓ | ✓ |
|
||||
| `author` | ✓ | ✗ |
|
||||
| GET | `/` | 首页 |
|
||||
| GET | `/search` | 搜索页 |
|
||||
| GET | `/api/articles` | 文章列表 API(无限滚动) |
|
||||
| GET | `/rss`、`/feed` | RSS 订阅 |
|
||||
| GET | `/article/:slug` | 文章详情 |
|
||||
| POST | `/article/:slug/comments` | 发表评论 |
|
||||
| GET | `/login` | 登录页 |
|
||||
| POST | `/login` | 提交登录 |
|
||||
| GET | `/register` | 注册页 |
|
||||
| POST | `/register` | 提交注册 |
|
||||
| POST | `/logout` | 退出登录 |
|
||||
| GET | `/uploads/*` | 静态文件(头像、附件等) |
|
||||
|
||||
## License
|
||||
### 管理后台(需管理员权限)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin` | 管理后台首页 |
|
||||
| GET | `/admin/articles` | 文章列表 |
|
||||
| GET/POST | `/admin/articles/new` | 新建文章 |
|
||||
| GET/POST | `/admin/articles/:id/edit` | 编辑文章 |
|
||||
| POST | `/admin/articles/:id/delete` | 删除文章 |
|
||||
| POST | `/admin/articles/attachments` | 上传附件 |
|
||||
| POST | `/admin/articles/attachments/:id/delete` | 删除附件 |
|
||||
| GET | `/admin/articles/:id/attachments` | 附件列表 |
|
||||
| GET | `/admin/comments` | 评论管理 |
|
||||
| POST | `/admin/comments/:id/approve` | 通过评论 |
|
||||
| POST | `/admin/comments/:id/reject` | 拒绝评论 |
|
||||
| POST | `/admin/comments/:id/delete` | 删除评论 |
|
||||
| GET | `/admin/users` | 用户列表 |
|
||||
| GET/POST | `/admin/users/new` | 新建用户 |
|
||||
| GET/POST | `/admin/users/:id/edit` | 编辑用户 |
|
||||
| POST | `/admin/users/:id/delete` | 删除用户 |
|
||||
| GET | `/admin/analytics/views` | 阅读统计 |
|
||||
| GET/POST | `/admin/settings/site` | 站点设置 |
|
||||
| GET/POST | `/admin/settings/navlinks` | 导航链接设置 |
|
||||
| GET/POST | `/admin/settings/upload` | 上传设置 |
|
||||
| GET/POST | `/admin/settings/download` | 下载设置 |
|
||||
| GET/POST | `/admin/settings/comments` | 评论设置 |
|
||||
|
||||
### 个人中心(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET/POST | `/profile` | 编辑个人信息 |
|
||||
| POST | `/profile/avatar` | 上传头像 |
|
||||
| GET | `/my/articles` | 我的文章列表 |
|
||||
| GET/POST | `/my/articles/new` | 新建文章 |
|
||||
| GET/POST | `/my/articles/:id/edit` | 编辑文章 |
|
||||
| POST | `/my/articles/:id/delete` | 删除文章 |
|
||||
| POST | `/my/articles/attachments` | 上传附件 |
|
||||
| POST | `/my/articles/attachments/:id/delete` | 删除附件 |
|
||||
| GET | `/my/articles/:id/attachments` | 附件列表 |
|
||||
|
||||
## 用户角色
|
||||
|
||||
| 角色 | 个人中心 | 我的文章 | 管理后台 |
|
||||
| --- | --- | --- | --- |
|
||||
| `admin` | ✓ | ✓ | ✓ |
|
||||
| `author` | ✓ | ✓ | ✗ |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
+28
-5
@@ -14,7 +14,7 @@ import (
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Port string `yaml:"port"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
Path string `yaml:"path"`
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
@@ -25,6 +25,12 @@ type DatabaseConfig struct {
|
||||
DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql")
|
||||
}
|
||||
|
||||
// WebConfig holds web-server listening configuration.
|
||||
type WebConfig struct {
|
||||
Port string `yaml:"port"` // TCP port, "" or "0" to disable
|
||||
Socket string `yaml:"socket"` // Unix socket path, "" to disable
|
||||
}
|
||||
|
||||
const defaultPort = "8080"
|
||||
|
||||
// mysqlExampleDSN is written into new config files as a reference.
|
||||
@@ -64,9 +70,21 @@ func generateSecret() string {
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// getDefaultSocketPath returns the OS-aware default unix socket path.
|
||||
func getDefaultSocketPath() string {
|
||||
if runtime.GOOS == "linux" {
|
||||
return "/run/blog_go/web.sock"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// LoadConfig reads the config file or creates one with defaults.
|
||||
func LoadConfig() *Config {
|
||||
// If customPath is non-empty, it overrides the OS-aware config file path.
|
||||
func LoadConfig(customPath string) *Config {
|
||||
configDir, configFile := getConfigPath()
|
||||
if customPath != "" {
|
||||
configFile = customPath
|
||||
configDir = filepath.Dir(configFile)
|
||||
}
|
||||
defaultPath := getDefaultStoragePath()
|
||||
|
||||
// Check if config file exists; create with defaults if not.
|
||||
@@ -81,7 +99,10 @@ func LoadConfig() *Config {
|
||||
Type: "sqlite",
|
||||
DSN: mysqlExampleDSN,
|
||||
},
|
||||
Port: defaultPort,
|
||||
Web: WebConfig{
|
||||
Port: defaultPort,
|
||||
Socket: getDefaultSocketPath(),
|
||||
},
|
||||
Path: defaultPath,
|
||||
Secret: generateSecret(),
|
||||
}
|
||||
@@ -116,8 +137,10 @@ func LoadConfig() *Config {
|
||||
|
||||
// applyDefaults fills zero-value fields with sensible defaults.
|
||||
func applyDefaults(cfg *Config, defaultPath string) *Config {
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = defaultPort
|
||||
// If the entire web block is empty (old config without "web" key),
|
||||
// fill default port so the app still starts on 8080.
|
||||
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
|
||||
cfg.Web.Port = defaultPort
|
||||
}
|
||||
if cfg.Database.Type == "" {
|
||||
cfg.Database.Type = "sqlite"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
root: .
|
||||
tmp_path: ./tmp
|
||||
build_args: -o ./tmp/go_blog
|
||||
build_log: ./tmp/build.log
|
||||
valid_ext: .go, .html
|
||||
build_delay: 300
|
||||
command: ./tmp/go_blog
|
||||
command_args:
|
||||
kill_delay: 500
|
||||
+17
-1
@@ -4,16 +4,32 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminDashboard renders the protected admin dashboard.
|
||||
func AdminDashboard() gin.HandlerFunc {
|
||||
func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
username, _ := c.Get("username")
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["dash_page_title"]
|
||||
data["Username"] = username
|
||||
|
||||
// Article counts: total and published.
|
||||
var postCount, publishedCount int64
|
||||
db.Model(&models.Article{}).Count(&postCount)
|
||||
db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished).Count(&publishedCount)
|
||||
data["PostCount"] = postCount
|
||||
data["PublishedCount"] = publishedCount
|
||||
|
||||
// User count for the stats card.
|
||||
var userCount int64
|
||||
db.Model(&models.User{}).Count(&userCount)
|
||||
data["UserCount"] = userCount
|
||||
|
||||
c.HTML(http.StatusOK, "dashboard", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewAnalyticsPage renders the admin analytics page showing article view statistics.
|
||||
func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
|
||||
// Parse filters from query params
|
||||
articleTitle := c.Query("article_title")
|
||||
userIDStr := c.Query("user_id")
|
||||
ip := c.Query("ip")
|
||||
showBots := c.Query("show_bots") == "1"
|
||||
page := 1
|
||||
if p := c.Query("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Build query for view records
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Preload("Article").
|
||||
Preload("User").
|
||||
Order("created_at DESC")
|
||||
|
||||
// Filter by article title (join with articles table)
|
||||
if articleTitle != "" {
|
||||
query = query.Joins("JOIN articles ON article_views.article_id = articles.id").
|
||||
Where("articles.title LIKE ?", "%"+articleTitle+"%")
|
||||
}
|
||||
if userIDStr != "" {
|
||||
query = query.Where("user_id = ?", userIDStr)
|
||||
}
|
||||
if ip != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+ip+"%")
|
||||
}
|
||||
if !showBots {
|
||||
query = query.Where("is_bot = ?", false)
|
||||
}
|
||||
|
||||
// Pagination
|
||||
pageSize := 50
|
||||
offset := (page - 1) * pageSize
|
||||
var views []models.ArticleView
|
||||
var totalCount int64
|
||||
query.Count(&totalCount)
|
||||
query.Limit(pageSize).Offset(offset).Find(&views)
|
||||
|
||||
hasMore := int64(offset+len(views)) < totalCount
|
||||
|
||||
// Calculate global statistics
|
||||
var stats struct {
|
||||
TotalViews int64
|
||||
UniqueIPs int64
|
||||
UniqueUsers int64
|
||||
BotViews int64
|
||||
HumanViews int64
|
||||
}
|
||||
db.Model(&models.ArticleView{}).Count(&stats.TotalViews)
|
||||
db.Model(&models.ArticleView{}).Where("is_bot = ?", true).Count(&stats.BotViews)
|
||||
db.Model(&models.ArticleView{}).Where("is_bot = ?", false).Count(&stats.HumanViews)
|
||||
db.Model(&models.ArticleView{}).Distinct("ip").Count(&stats.UniqueIPs)
|
||||
db.Model(&models.ArticleView{}).Where("user_id IS NOT NULL").Distinct("user_id").Count(&stats.UniqueUsers)
|
||||
|
||||
// Per-article statistics
|
||||
type ArticleStat struct {
|
||||
ArticleID uint
|
||||
Title string
|
||||
ViewCount int64
|
||||
UniqueIPs int64
|
||||
BotViews int64
|
||||
HumanViews int64
|
||||
}
|
||||
var articleStats []ArticleStat
|
||||
|
||||
db.Raw(`
|
||||
SELECT
|
||||
av.article_id,
|
||||
a.title,
|
||||
COUNT(*) as view_count,
|
||||
COUNT(DISTINCT av.ip) as unique_ips,
|
||||
SUM(CASE WHEN av.is_bot THEN 1 ELSE 0 END) as bot_views,
|
||||
SUM(CASE WHEN NOT av.is_bot THEN 1 ELSE 0 END) as human_views
|
||||
FROM article_views av
|
||||
LEFT JOIN articles a ON av.article_id = a.id
|
||||
WHERE a.deleted_at IS NULL
|
||||
GROUP BY av.article_id, a.title
|
||||
ORDER BY view_count DESC
|
||||
LIMIT 20
|
||||
`).Scan(&articleStats)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["analytics_views_title"]
|
||||
data["Views"] = views
|
||||
data["Stats"] = stats
|
||||
data["ArticleStats"] = articleStats
|
||||
data["Filters"] = gin.H{
|
||||
"ArticleTitle": articleTitle,
|
||||
"UserID": userIDStr,
|
||||
"IP": ip,
|
||||
"ShowBots": showBots,
|
||||
"Page": page,
|
||||
}
|
||||
data["Pagination"] = gin.H{
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"Total": totalCount,
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "analytics_views", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// adminCommentPageSize bounds the number of comments shown per admin page.
|
||||
const adminCommentPageSize = 30
|
||||
|
||||
// commentListView is a Comment plus derived display fields for the admin list.
|
||||
type commentListView struct {
|
||||
models.Comment
|
||||
GravatarURL string
|
||||
MaskedEmail string
|
||||
StatusLabel string
|
||||
StatusBadge string
|
||||
ArticleTitle string
|
||||
ArticleSlug string
|
||||
}
|
||||
|
||||
// CommentListPage renders the admin comment moderation list, filtered by
|
||||
// status via ?status=pending|approved|rejected|all.
|
||||
func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
q := db.Model(&models.Comment{}).Order("created_at DESC")
|
||||
switch status {
|
||||
case "approved":
|
||||
q = q.Where("status = ?", models.CommentApproved)
|
||||
case "rejected":
|
||||
q = q.Where("status = ?", models.CommentRejected)
|
||||
case "all":
|
||||
// no filter
|
||||
default: // pending
|
||||
status = "pending"
|
||||
q = q.Where("status = ?", models.CommentPending)
|
||||
}
|
||||
|
||||
var comments []models.Comment
|
||||
q.Limit(adminCommentPageSize).Find(&comments)
|
||||
|
||||
// Pull referenced articles in one query to avoid N+1.
|
||||
ids := make(map[uint]struct{})
|
||||
for _, cm := range comments {
|
||||
ids[cm.ArticleID] = struct{}{}
|
||||
}
|
||||
articleMap := make(map[uint]models.Article)
|
||||
if len(ids) > 0 {
|
||||
idList := make([]uint, 0, len(ids))
|
||||
for id := range ids {
|
||||
idList = append(idList, id)
|
||||
}
|
||||
var articles []models.Article
|
||||
db.Unscoped().Where("id IN ?", idList).Find(&articles)
|
||||
for _, a := range articles {
|
||||
articleMap[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
views := make([]commentListView, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
v := commentListView{
|
||||
Comment: cm,
|
||||
GravatarURL: cm.GravatarURL(40),
|
||||
MaskedEmail: cm.MaskedEmail(),
|
||||
}
|
||||
if a, ok := articleMap[cm.ArticleID]; ok {
|
||||
v.ArticleTitle = a.Title
|
||||
v.ArticleSlug = a.Slug
|
||||
}
|
||||
switch cm.Status {
|
||||
case models.CommentPending:
|
||||
v.StatusLabel = tr["comment_status_pending"]
|
||||
v.StatusBadge = "bg-yellow-100 text-yellow-700"
|
||||
case models.CommentApproved:
|
||||
v.StatusLabel = tr["comment_status_approved"]
|
||||
v.StatusBadge = "bg-green-100 text-green-700"
|
||||
case models.CommentRejected:
|
||||
v.StatusLabel = tr["comment_status_rejected"]
|
||||
v.StatusBadge = "bg-red-100 text-red-700"
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
// Pending count for the tab badge.
|
||||
var pendingCount int64
|
||||
db.Model(&models.Comment{}).Where("status = ?", models.CommentPending).Count(&pendingCount)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["admin_comments_title"]
|
||||
data["Comments"] = views
|
||||
data["Status"] = status
|
||||
data["PendingCount"] = pendingCount
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
switch c.Query("msg") {
|
||||
case "approved":
|
||||
data["Success"] = tr["comment_approved"]
|
||||
case "rejected":
|
||||
data["Success"] = tr["comment_rejected"]
|
||||
case "deleted":
|
||||
data["Success"] = tr["comment_deleted"]
|
||||
default:
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, "comment_list", data)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentApprove marks a comment approved.
|
||||
func CommentApprove(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved")
|
||||
}
|
||||
}
|
||||
|
||||
// CommentReject marks a comment rejected (hidden from the front end, retained
|
||||
// in the admin list).
|
||||
func CommentReject(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// CommentDelete soft-deletes a comment.
|
||||
func CommentDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Where("id = ?", id).Delete(&models.Comment{})
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// userForm holds the posted user fields plus rendering metadata for the shared
|
||||
// create/edit form template.
|
||||
type userForm struct {
|
||||
ID uint
|
||||
Username string
|
||||
Password string
|
||||
DisplayName string
|
||||
Email string
|
||||
Gender string
|
||||
Birthday string // YYYY-MM-DD from the date input
|
||||
Role string
|
||||
Status int
|
||||
IsEdit bool
|
||||
Action string
|
||||
TitleText string
|
||||
}
|
||||
|
||||
// userListView augments a User with pre-rendered labels/badges so the template
|
||||
// never invokes methods on an interface{}-wrapped struct.
|
||||
type userListView struct {
|
||||
models.User
|
||||
RoleLabel string
|
||||
RoleBadge string
|
||||
StatusLabel string
|
||||
StatusBadge string
|
||||
}
|
||||
|
||||
// parseUserForm reads the user form fields from the request.
|
||||
func parseUserForm(c *gin.Context) userForm {
|
||||
// Status is always sent from the <select> (0/1/2/3); treat an absent field
|
||||
// as Normal, but keep an explicit 0 (Disabled) intact.
|
||||
status := models.StatusNormal
|
||||
if raw := strings.TrimSpace(c.PostForm("status")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
status = n
|
||||
}
|
||||
}
|
||||
return userForm{
|
||||
ID: uintFormID(c.Param("id")),
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
DisplayName: strings.TrimSpace(c.PostForm("display_name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Gender: strings.TrimSpace(c.PostForm("gender")),
|
||||
Birthday: strings.TrimSpace(c.PostForm("birthday")),
|
||||
Role: strings.TrimSpace(c.PostForm("role")),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// uintFormID parses a route :id into a uint (0 when absent/invalid).
|
||||
func uintFormID(s string) uint {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
// applyUserFormToData writes the form values into the template data map so the
|
||||
// form is repopulated on render (initial load or validation error).
|
||||
func applyUserFormToData(data gin.H, f userForm) {
|
||||
data["FormID"] = f.ID
|
||||
data["FormUsername"] = f.Username
|
||||
data["FormPassword"] = f.Password
|
||||
data["FormDisplayName"] = f.DisplayName
|
||||
data["FormEmail"] = f.Email
|
||||
data["FormGender"] = f.Gender
|
||||
data["FormBirthday"] = f.Birthday
|
||||
data["FormRole"] = f.Role
|
||||
data["FormStatus"] = f.Status
|
||||
data["FormIsEdit"] = f.IsEdit
|
||||
data["FormAction"] = f.Action
|
||||
data["FormTitleText"] = f.TitleText
|
||||
}
|
||||
|
||||
// renderUserForm renders the shared user form template with the given form
|
||||
// values and optional error message.
|
||||
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
// Role/status options for the <select> elements.
|
||||
data["RoleAdmin"] = models.RoleAdmin
|
||||
data["RoleAuthor"] = models.RoleAuthor
|
||||
data["StatusNormal"] = models.StatusNormal
|
||||
data["StatusDisabled"] = models.StatusDisabled
|
||||
data["StatusLocked"] = models.StatusLocked
|
||||
data["StatusUnactivated"] = models.StatusUnactivated
|
||||
data["TrGenderMale"] = tr["gender_male"]
|
||||
data["TrGenderFemale"] = tr["gender_female"]
|
||||
data["TrGenderOther"] = tr["gender_other"]
|
||||
applyUserFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "user_form", data)
|
||||
}
|
||||
|
||||
// UserListPage renders the admin user management list.
|
||||
func UserListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var users []models.User
|
||||
db.Order("created_at DESC").Find(&users)
|
||||
|
||||
views := make([]userListView, 0, len(users))
|
||||
for _, u := range users {
|
||||
v := userListView{User: u}
|
||||
switch u.Role {
|
||||
case models.RoleAdmin:
|
||||
v.RoleLabel = tr["role_admin"]
|
||||
v.RoleBadge = "bg-purple-100 text-purple-700"
|
||||
default:
|
||||
v.RoleLabel = tr["role_author"]
|
||||
v.RoleBadge = "bg-blue-100 text-blue-700"
|
||||
}
|
||||
switch u.Status {
|
||||
case models.StatusNormal:
|
||||
v.StatusLabel = tr["status_normal"]
|
||||
v.StatusBadge = "bg-green-100 text-green-700"
|
||||
case models.StatusDisabled:
|
||||
v.StatusLabel = tr["status_disabled"]
|
||||
v.StatusBadge = "bg-gray-200 text-gray-600"
|
||||
case models.StatusLocked:
|
||||
v.StatusLabel = tr["status_locked"]
|
||||
v.StatusBadge = "bg-yellow-100 text-yellow-700"
|
||||
default:
|
||||
v.StatusLabel = tr["status_unactivated"]
|
||||
v.StatusBadge = "bg-red-100 text-red-700"
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["admin_users_title"]
|
||||
data["Users"] = views
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
switch c.Query("msg") {
|
||||
case "created":
|
||||
data["Success"] = tr["user_created"]
|
||||
case "updated":
|
||||
data["Success"] = tr["user_updated"]
|
||||
case "deleted":
|
||||
data["Success"] = tr["user_deleted"]
|
||||
default:
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
}
|
||||
if e := c.Query("error"); e != "" {
|
||||
switch e {
|
||||
case "self_disable":
|
||||
data["Error"] = tr["user_cannot_disable_self"]
|
||||
case "last_admin":
|
||||
data["Error"] = tr["user_cannot_remove_last_admin"]
|
||||
case "exists":
|
||||
data["Error"] = tr["user_username_exists"]
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, "user_list", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UserCreatePage renders the empty user creation form.
|
||||
func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
renderUserForm(c, userForm{
|
||||
Action: "/admin/users/new",
|
||||
TitleText: tr["user_create_title"],
|
||||
Role: models.RoleAuthor,
|
||||
Status: models.StatusNormal,
|
||||
IsEdit: false,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// UserCreate handles POST to create a new user.
|
||||
func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseUserForm(c)
|
||||
f.Action = "/admin/users/new"
|
||||
f.TitleText = tr["user_create_title"]
|
||||
f.IsEdit = false
|
||||
|
||||
if f.Username == "" {
|
||||
renderUserForm(c, f, tr["user_username_required"])
|
||||
return
|
||||
}
|
||||
if f.Password == "" {
|
||||
renderUserForm(c, f, tr["user_password_required"])
|
||||
return
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = models.RoleAuthor
|
||||
}
|
||||
|
||||
// Username must be unique.
|
||||
var exists int64
|
||||
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
|
||||
if exists > 0 {
|
||||
renderUserForm(c, f, tr["user_username_exists"])
|
||||
return
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Username: f.Username,
|
||||
DisplayName: f.DisplayName,
|
||||
Email: f.Email,
|
||||
Gender: f.Gender,
|
||||
Role: f.Role,
|
||||
Status: f.Status,
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
|
||||
user.Birthday = &t
|
||||
}
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created")
|
||||
}
|
||||
}
|
||||
|
||||
// UserEditPage renders the user edit form prefilled with an existing user.
|
||||
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
birthday := ""
|
||||
if user.Birthday != nil {
|
||||
birthday = user.Birthday.Format("2006-01-02")
|
||||
}
|
||||
renderUserForm(c, userForm{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Gender: user.Gender,
|
||||
Birthday: birthday,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
IsEdit: true,
|
||||
Action: "/admin/users/" + id + "/edit",
|
||||
TitleText: tr["user_edit_title"],
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// UserUpdate handles POST to update an existing user.
|
||||
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
f := parseUserForm(c)
|
||||
f.ID = uintFormID(id)
|
||||
f.IsEdit = true
|
||||
f.Action = "/admin/users/" + id + "/edit"
|
||||
f.TitleText = tr["user_edit_title"]
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the original username (it is the login key + article FK source).
|
||||
f.Username = user.Username
|
||||
|
||||
currentID := userIDFromSession(c)
|
||||
isSelf := user.ID == currentID
|
||||
|
||||
// Self-protection: cannot disable/lock your own account.
|
||||
if isSelf && f.Status != models.StatusNormal {
|
||||
f.DisplayName = user.DisplayName
|
||||
f.Email = user.Email
|
||||
f.Gender = user.Gender
|
||||
f.Role = user.Role
|
||||
f.Status = user.Status
|
||||
if user.Birthday != nil {
|
||||
f.Birthday = user.Birthday.Format("2006-01-02")
|
||||
}
|
||||
renderUserForm(c, f, tr["user_cannot_disable_self"])
|
||||
return
|
||||
}
|
||||
|
||||
// Self-protection: cannot demote the last remaining admin.
|
||||
if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = user.Role
|
||||
}
|
||||
|
||||
user.DisplayName = f.DisplayName
|
||||
user.Email = f.Email
|
||||
user.Gender = f.Gender
|
||||
user.Role = f.Role
|
||||
user.Status = f.Status
|
||||
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
|
||||
user.Birthday = &t
|
||||
} else {
|
||||
user.Birthday = nil
|
||||
}
|
||||
|
||||
// Optional password reset (leave blank to keep current).
|
||||
if f.Password != "" {
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated")
|
||||
}
|
||||
}
|
||||
|
||||
// UserDelete soft-deletes a user, with self-protection and last-admin guards.
|
||||
func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
targetID := uintFormID(id)
|
||||
currentID := userIDFromSession(c)
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot delete yourself.
|
||||
if targetID == currentID {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=self_disable")
|
||||
return
|
||||
}
|
||||
// Cannot delete the last admin.
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.Delete(&user)
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted")
|
||||
}
|
||||
}
|
||||
+418
-70
@@ -1,8 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,27 +17,249 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// slugRe matches characters that are not URL-safe.
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9\-]+`)
|
||||
// slugSepRe matches runs of non-word characters (anything that is not a
|
||||
// letter or digit); these become single dashes.
|
||||
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
// generateSlug converts a title into a URL-friendly slug.
|
||||
// slugDashRe matches runs of dashes to collapse.
|
||||
var slugDashRe = regexp.MustCompile(`-{2,}`)
|
||||
|
||||
// slugAsciiRe matches a slug made only of URL-safe ASCII letters, digits and
|
||||
// dashes. Slugs containing other characters (e.g. CJK, or Unicode lowercase
|
||||
// quirks like the Turkish dotless i) are not URL-stable and are discarded in
|
||||
// favor of a "post-<id>" fallback.
|
||||
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
// randomToken returns a 32-byte hex token used to own pending attachments on
|
||||
// the article-create page until the article is saved.
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Extremely unlikely; fall back to a time-based value.
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateSlug converts a title into a URL-friendly ASCII slug. Returns "" when
|
||||
// the title yields no URL-safe ASCII characters (the caller must fall back,
|
||||
// e.g. to "post-<id>"). Non-ASCII letters are intentionally dropped rather
|
||||
// than kept, because raw CJK in a URL slug is not stable.
|
||||
func generateSlug(title string) string {
|
||||
s := strings.ToLower(title)
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = slugRe.ReplaceAllString(s, "")
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = slugSepRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
// Collapse multiple dashes.
|
||||
s = regexp.MustCompile(`-{2,}`).ReplaceAllString(s, "-")
|
||||
s = slugDashRe.ReplaceAllString(s, "-")
|
||||
if !slugAsciiRe.MatchString(s) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fallbackSlug returns a non-empty slug when title-derived slugs are empty
|
||||
// (e.g. a title of only punctuation/whitespace, or all-stripped). Uses the
|
||||
// article ID when available, else a short token.
|
||||
func fallbackSlug(id uint) string {
|
||||
if id > 0 {
|
||||
return fmt.Sprintf("post-%d", id)
|
||||
}
|
||||
return "post-" + randomToken()[:8]
|
||||
}
|
||||
|
||||
// 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
|
||||
PublishedAt string // datetime-local format: "2006-01-02T15:04"
|
||||
Tags string // comma-separated tag names
|
||||
Action string // form action URL
|
||||
TitleText string // page heading text (create vs edit)
|
||||
ArticleID uint // existing article ID (edit page); 0 on create
|
||||
SessionToken string // pending-attachment ownership token (create page)
|
||||
}
|
||||
|
||||
// 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",
|
||||
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
|
||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||
}
|
||||
}
|
||||
|
||||
// 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["FormPublishedAt"] = f.PublishedAt
|
||||
data["FormTags"] = f.Tags
|
||||
data["FormAction"] = f.Action
|
||||
data["FormTitleText"] = f.TitleText
|
||||
data["FormArticleID"] = f.ArticleID
|
||||
data["SessionToken"] = f.SessionToken
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from
|
||||
// the form into a time.Time pointer. Returns nil if the string is empty or invalid.
|
||||
func parsePublishedAt(publishedAtStr string) *time.Time {
|
||||
if publishedAtStr == "" {
|
||||
return nil
|
||||
}
|
||||
// datetime-local format: "2006-01-02T15:04"
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// formatPublishedAt formats a time.Time pointer into datetime-local format for the form.
|
||||
// Returns empty string if the pointer is nil.
|
||||
func formatPublishedAt(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02T15:04")
|
||||
}
|
||||
|
||||
// parseTags splits comma-separated tag string and returns tag names.
|
||||
func parseTags(tagStr string) []string {
|
||||
if tagStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagStr, ",")
|
||||
var tags []string
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
tags = append(tags, trimmed)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// syncArticleTags associates tags with an article (find or create tags).
|
||||
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
|
||||
// Clear existing tags
|
||||
if err := db.Model(article).Association("Tags").Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no tags, we're done
|
||||
if len(tagNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find or create each tag and associate with article
|
||||
var tags []models.Tag
|
||||
for _, name := range tagNames {
|
||||
tag, err := models.FindOrCreateTag(db, name, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag != nil {
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Associate tags with article
|
||||
if len(tags) > 0 {
|
||||
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update tag counts
|
||||
for _, tag := range tags {
|
||||
models.UpdateTagCount(db, tag.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatArticleTags converts article tags to comma-separated string for form.
|
||||
func formatArticleTags(tags []models.Tag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
var names []string
|
||||
for _, tag := range tags {
|
||||
names = append(names, tag.NameZh)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// ArticleCreatePage renders the article creation form.
|
||||
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_create_title"]
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
renderArticleForm(c, db, articleForm{
|
||||
Action: "/admin/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
StatusStr: "0",
|
||||
SessionToken: randomToken(),
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,95 +267,217 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
|
||||
title := strings.TrimSpace(c.PostForm("title"))
|
||||
content := strings.TrimSpace(c.PostForm("content"))
|
||||
slug := strings.TrimSpace(c.PostForm("slug"))
|
||||
summary := strings.TrimSpace(c.PostForm("summary"))
|
||||
cover := strings.TrimSpace(c.PostForm("cover"))
|
||||
statusStr := c.PostForm("status")
|
||||
isTop := c.PostForm("is_top") == "1"
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/admin/articles/new"
|
||||
f.TitleText = tr["article_create_title"]
|
||||
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
|
||||
|
||||
// Validate required fields.
|
||||
if title == "" {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_create_title"]
|
||||
data["Error"] = tr["article_title_required"]
|
||||
data["FormTitle"] = title
|
||||
data["FormSlug"] = slug
|
||||
data["FormSummary"] = summary
|
||||
data["FormContent"] = content
|
||||
data["FormCover"] = cover
|
||||
data["FormStatus"] = statusStr
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
if f.Title == "" {
|
||||
renderArticleForm(c, db, f, tr["article_title_required"])
|
||||
return
|
||||
}
|
||||
if content == "" {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_create_title"]
|
||||
data["Error"] = tr["article_content_required"]
|
||||
data["FormTitle"] = title
|
||||
data["FormSlug"] = slug
|
||||
data["FormSummary"] = summary
|
||||
data["FormContent"] = content
|
||||
data["FormCover"] = cover
|
||||
data["FormStatus"] = statusStr
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
if f.Content == "" {
|
||||
renderArticleForm(c, db, f, tr["article_content_required"])
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate slug if empty.
|
||||
if slug == "" {
|
||||
slug = generateSlug(title)
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
// Title had no usable characters (e.g. only punctuation). Use a
|
||||
// temporary token-based slug now; refine to post-<id> after insert.
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse status: default to draft (0).
|
||||
status := models.ArticleDraft
|
||||
if statusStr == "1" {
|
||||
status = models.ArticlePublished
|
||||
}
|
||||
status := statusFromForm(f.StatusStr)
|
||||
|
||||
// Get author ID from session.
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
authorID, ok := sessionAuthorID(c)
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
var publishedAt *time.Time
|
||||
if status == models.ArticlePublished {
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else if status == models.ArticlePublished {
|
||||
// Auto-stamp with current time if publishing without custom time
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
article := models.Article{
|
||||
AuthorID: uint(userID.(int)),
|
||||
Title: title,
|
||||
Slug: slug,
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Cover: cover,
|
||||
AuthorID: authorID,
|
||||
Title: f.Title,
|
||||
Slug: f.Slug,
|
||||
Summary: f.Summary,
|
||||
Content: f.Content,
|
||||
Cover: f.Cover,
|
||||
Status: status,
|
||||
IsTop: isTop,
|
||||
IsTop: f.IsTop,
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Create(&article).Error; err != nil {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_create_title"]
|
||||
data["Error"] = tr["article_error"]
|
||||
data["FormTitle"] = title
|
||||
data["FormSlug"] = slug
|
||||
data["FormSummary"] = summary
|
||||
data["FormContent"] = content
|
||||
data["FormCover"] = cover
|
||||
data["FormStatus"] = statusStr
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
renderArticleForm(c, db, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
|
||||
// Refine a token-based placeholder slug to the readable post-<id> form.
|
||||
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
|
||||
if newSlug := fallbackSlug(article.ID); newSlug != "" {
|
||||
db.Model(&article).Update("slug", newSlug)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync article tags
|
||||
tagNames := parseTags(f.Tags)
|
||||
if err := syncArticleTags(db, &article, tagNames); err != nil {
|
||||
// Log error but don't fail the article creation
|
||||
// The article is already created, tags are optional
|
||||
}
|
||||
|
||||
// Bind any attachments uploaded during creation (plan A: pending rows
|
||||
// owned by session_token, article_id=0).
|
||||
if f.SessionToken != "" {
|
||||
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
|
||||
}
|
||||
|
||||
// Success: redirect to dashboard.
|
||||
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
|
||||
}
|
||||
|
||||
// Preload tags for the article
|
||||
db.Model(&article).Association("Tags").Find(&article.Tags)
|
||||
|
||||
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,
|
||||
PublishedAt: formatPublishedAt(article.PublishedAt),
|
||||
Tags: formatArticleTags(article.Tags),
|
||||
Action: "/admin/articles/" + id + "/edit",
|
||||
TitleText: tr["article_edit_title"],
|
||||
ArticleID: article.ID,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(article.ID)
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
|
||||
var publishedAt *time.Time
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else {
|
||||
// Auto-stamp the publish time the first time an article is published.
|
||||
wasPublished := article.Status == models.ArticlePublished
|
||||
publishedAt = 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
|
||||
}
|
||||
|
||||
// Sync article tags
|
||||
tagNames := parseTags(f.Tags)
|
||||
if err := syncArticleTags(db, &article, tagNames); err != nil {
|
||||
// Log error but don't fail the article update
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// attachmentsDir returns the on-disk directory for attachments under the given
|
||||
// storage path, honoring the configured storage_dir.
|
||||
func attachmentsDir(storagePath string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
dir = "attachments"
|
||||
}
|
||||
return filepath.Join(storagePath, dir)
|
||||
}
|
||||
|
||||
// attachmentRelPath returns the path of an attachment relative to the storage
|
||||
// root, e.g. "attachments/<stored>" — used to build /uploads URLs.
|
||||
func attachmentRelPath(stored string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
dir = "attachments"
|
||||
}
|
||||
return dir + "/" + stored
|
||||
}
|
||||
|
||||
// attachmentURL builds the public URL for an attachment: the default download
|
||||
// base URL (if configured) joined with the relative path, else the local
|
||||
// /uploads path served by the app.
|
||||
func attachmentURL(stored string) string {
|
||||
rel := attachmentRelPath(stored)
|
||||
if base := models.DefaultDownloadBaseURL(); base != "" {
|
||||
return strings.TrimRight(base, "/") + "/" + rel
|
||||
}
|
||||
return "/uploads/" + rel
|
||||
}
|
||||
|
||||
// ---------------- Upload ----------------
|
||||
|
||||
// UploadAttachment handles AJAX attachment uploads from the article create/edit
|
||||
// form. The request carries either a real article_id (edit page) or a
|
||||
// session_token (create page, pending binding). Files are content-addressed by
|
||||
// SHA-256 for on-disk deduplication.
|
||||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uploaderID, ok := sessionAuthorID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
articleID := parseUintForm(c, "article_id")
|
||||
token := strings.TrimSpace(c.PostForm("session_token"))
|
||||
// On the create page the article does not exist yet; require a token.
|
||||
if articleID == 0 && token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
c.JSON(http.StatusForbidden, 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
|
||||
}
|
||||
|
||||
// Read fully to compute the content hash.
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
stored := hex.EncodeToString(sum[:])
|
||||
|
||||
// Deduplicate on disk: only write when the file is absent.
|
||||
dir := attachmentsDir(storagePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
|
||||
return
|
||||
}
|
||||
dstPath := filepath.Join(dir, stored)
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
att := models.Attachment{
|
||||
ArticleID: articleID,
|
||||
SessionToken: token,
|
||||
UploaderID: uploaderID,
|
||||
Filename: header.Filename,
|
||||
StoredName: stored,
|
||||
Ext: ext,
|
||||
MIME: header.Header.Get("Content-Type"),
|
||||
Size: header.Size,
|
||||
Category: check.Type.Category,
|
||||
}
|
||||
if err := db.Create(&att).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": att.ID,
|
||||
"filename": att.Filename,
|
||||
"size": att.Size,
|
||||
"category": att.Category,
|
||||
"url": attachmentURL(att.StoredName),
|
||||
"is_image": att.IsImage(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Delete ----------------
|
||||
|
||||
// DeleteAttachment soft-deletes an attachment record and removes the on-disk
|
||||
// file only when no remaining records reference it (reference counting, since
|
||||
// content-addressed files may be shared).
|
||||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
var att models.Attachment
|
||||
if err := db.First(&att, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
stored := att.StoredName
|
||||
|
||||
if err := db.Delete(&att).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reference count: any other (non-deleted) rows pointing at this file?
|
||||
var count int64
|
||||
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
|
||||
if count == 0 {
|
||||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // ignore error
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- List ----------------
|
||||
|
||||
// ListAttachments returns the attachments for an article as JSON (used by the
|
||||
// edit page to repopulate the list on load).
|
||||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
articleID := parseUintParam(c, "id")
|
||||
var atts []models.Attachment
|
||||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||||
|
||||
out := make([]gin.H, 0, len(atts))
|
||||
for _, a := range atts {
|
||||
out = append(out, gin.H{
|
||||
"id": a.ID,
|
||||
"filename": a.Filename,
|
||||
"size": a.Size,
|
||||
"category": a.Category,
|
||||
"url": attachmentURL(a.StoredName),
|
||||
"is_image": a.IsImage(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": out})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Binding (plan A) ----------------
|
||||
|
||||
// BindPendingAttachments attaches attachments uploaded during article creation
|
||||
// (owned by session_token, article_id=0) to a newly created article. Called by
|
||||
// ArticleCreate after the article row is saved.
|
||||
func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Model(&models.Attachment{}).
|
||||
Where("session_token = ? AND article_id = 0", token).
|
||||
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
// parseUintForm parses a uint form field, tolerating empty/invalid input.
|
||||
func parseUintForm(c *gin.Context, field string) uint {
|
||||
v := strings.TrimSpace(c.PostForm(field))
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseUintParam parses a uint route param.
|
||||
func parseUintParam(c *gin.Context, name string) uint {
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
|
||||
return n
|
||||
}
|
||||
+121
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +46,12 @@ func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse login for non-normal accounts (disabled / locked / unactivated).
|
||||
if user.Status != models.StatusNormal {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session.
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", user.ID)
|
||||
@@ -49,7 +61,12 @@ func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
// Redirect based on user role: admins to /admin, others to home
|
||||
if user.Role == models.RoleAdmin {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,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, "/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// MaxCommentLength bounds the size of a single comment body, in characters.
|
||||
const MaxCommentLength = 4000
|
||||
|
||||
// guestCookieName is the long-lived cookie used to identify anonymous
|
||||
// commenters so they can see their own pending/private comments.
|
||||
const guestCookieName = "comment_uid"
|
||||
const guestCookieMaxAge = 365 * 24 * 3600 // one year
|
||||
|
||||
// htmlTagPattern matches any HTML/XML tag so it can be stripped from comment
|
||||
// markdown before storage. Markdown syntax itself contains no angle brackets
|
||||
// in a form that would collide (the only such construct is autolinks like
|
||||
// <http://…>, which are rare in comments and acceptable to lose).
|
||||
var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
|
||||
// dangerousSchemePattern matches dangerous URL schemes inside markdown link
|
||||
// targets that could execute script when rendered to innerHTML.
|
||||
var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`)
|
||||
|
||||
// commentForm holds the parsed values of a submitted comment form so the
|
||||
// template can refill the inputs after a validation failure.
|
||||
type commentForm struct {
|
||||
Name string
|
||||
Email string
|
||||
Website string
|
||||
Content string
|
||||
IsPrivate bool
|
||||
ParentID string
|
||||
}
|
||||
|
||||
// sanitizeMarkdown strips HTML tags and dangerous URL schemes from a comment
|
||||
// body before storage. Markdown syntax is preserved so the frontend can render
|
||||
// it. This is the first of two XSS defenses; the frontend also runs the output
|
||||
// through marked + DOMPurify.
|
||||
func sanitizeMarkdown(s string) string {
|
||||
s = htmlTagPattern.ReplaceAllString(s, "")
|
||||
s = dangerousSchemePattern.ReplaceAllString(s, "#")
|
||||
// Collapse runs of more than two newlines.
|
||||
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// newGuestToken generates a random hex token for an anonymous commenter.
|
||||
func newGuestToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Extremely unlikely; fall back to a timestamp-based token.
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// guestTokenFrom reads the anonymous commenter cookie, creating and setting a
|
||||
// new one when absent. The token is returned for storage on the comment.
|
||||
func guestTokenFrom(c *gin.Context) string {
|
||||
token, _ := c.Cookie(guestCookieName)
|
||||
if token == "" {
|
||||
token = newGuestToken()
|
||||
}
|
||||
// (Re)set the cookie so returning visitors keep their identity. HttpOnly
|
||||
// prevents JS access; SameSite=Lax is the gin default and is appropriate.
|
||||
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true)
|
||||
return token
|
||||
}
|
||||
|
||||
// emailHash returns the md5 of a lowercased, trimmed email, per the Gravatar
|
||||
// spec.
|
||||
func emailHash(email string) string {
|
||||
h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// PostComment handles submission of a new comment (or reply) on an article.
|
||||
func PostComment(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
slug := c.Param("slug")
|
||||
|
||||
var article models.Article
|
||||
if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_not_found"]
|
||||
c.HTML(http.StatusNotFound, "article_not_found", data)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := models.GetCommentConfig()
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "")
|
||||
return
|
||||
}
|
||||
|
||||
isLoggedIn, _ := c.Get("is_logged_in")
|
||||
loggedIn, _ := isLoggedIn.(bool)
|
||||
if !loggedIn && !cfg.AllowGuest {
|
||||
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_guests_disabled"], "")
|
||||
return
|
||||
}
|
||||
|
||||
form := commentForm{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Website: strings.TrimSpace(c.PostForm("website")),
|
||||
Content: strings.TrimSpace(c.PostForm("content")),
|
||||
IsPrivate: c.PostForm("is_private") == "1",
|
||||
ParentID: strings.TrimSpace(c.PostForm("parent_id")),
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
if form.Name == "" || len(form.Name) > 64 {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "")
|
||||
return
|
||||
}
|
||||
if form.Email == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "")
|
||||
return
|
||||
}
|
||||
if _, err := mail.ParseAddress(form.Email); err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "")
|
||||
return
|
||||
}
|
||||
if form.Website != "" {
|
||||
u, err := url.Parse(form.Website)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "")
|
||||
return
|
||||
}
|
||||
}
|
||||
if form.Content == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
return
|
||||
}
|
||||
if len([]rune(form.Content)) > MaxCommentLength {
|
||||
renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "")
|
||||
return
|
||||
}
|
||||
|
||||
// --- Parent validation ---
|
||||
var parentID *uint
|
||||
if form.ParentID != "" {
|
||||
pid, err := strconv.ParseUint(form.ParentID, 10, 64)
|
||||
if err != nil || pid == 0 {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
return
|
||||
}
|
||||
var parent models.Comment
|
||||
if err := db.Where("id = ? AND article_id = ? AND status = ?", pid, article.ID, models.CommentApproved).First(&parent).Error; err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
return
|
||||
}
|
||||
id := uint(pid)
|
||||
parentID = &id
|
||||
}
|
||||
|
||||
// --- Build comment ---
|
||||
comment := models.Comment{
|
||||
ArticleID: article.ID,
|
||||
ParentID: parentID,
|
||||
AuthorName: form.Name,
|
||||
Email: form.Email,
|
||||
EmailHash: emailHash(form.Email),
|
||||
Website: form.Website,
|
||||
Content: sanitizeMarkdown(form.Content),
|
||||
IsPrivate: form.IsPrivate,
|
||||
Status: models.CommentApproved,
|
||||
IPAddress: truncate(GetClientIP(c), 64),
|
||||
UserAgent: truncate(c.GetHeader("User-Agent"), 512),
|
||||
}
|
||||
|
||||
if loggedIn {
|
||||
uid := userIDFromSession(c)
|
||||
if uid != 0 {
|
||||
comment.UserID = &uid
|
||||
}
|
||||
comment.Status = models.CommentApproved
|
||||
} else {
|
||||
comment.GuestToken = guestTokenFrom(c)
|
||||
if cfg.GuestRequireApproval {
|
||||
comment.Status = models.CommentPending
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&comment).Error; err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["article_error"], "")
|
||||
return
|
||||
}
|
||||
|
||||
anchor := fmt.Sprintf("#comment-%d", comment.ID)
|
||||
if comment.Status == models.CommentPending {
|
||||
setCommentFlash(c, getTr(c)["comments_pending_notice"])
|
||||
} else {
|
||||
setCommentFlash(c, getTr(c)["comments_posted"])
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/article/"+slug+anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// truncate clips s to at most n runes.
|
||||
func truncate(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// commentViewer describes the identity of the current request for the purposes
|
||||
// of comment visibility.
|
||||
type commentViewer struct {
|
||||
userID *uint
|
||||
isAdmin bool
|
||||
guestToken string
|
||||
}
|
||||
|
||||
// viewerFromContext builds a commentViewer from the request/session.
|
||||
func viewerFromContext(c *gin.Context) commentViewer {
|
||||
v := commentViewer{}
|
||||
if uid := userIDFromSession(c); uid != 0 {
|
||||
v.userID = &uid
|
||||
}
|
||||
if role, ok := c.Get("role"); ok {
|
||||
if r, _ := role.(string); r == models.RoleAdmin {
|
||||
v.isAdmin = true
|
||||
}
|
||||
}
|
||||
v.guestToken, _ = c.Cookie(guestCookieName)
|
||||
return v
|
||||
}
|
||||
|
||||
// canSee reports whether the viewer is allowed to see one comment.
|
||||
func (v commentViewer) canSee(c *models.Comment) bool {
|
||||
switch c.Status {
|
||||
case models.CommentApproved:
|
||||
if !c.IsPrivate {
|
||||
return true
|
||||
}
|
||||
// Private: admin or author only.
|
||||
return v.isAdmin || v.owns(c)
|
||||
case models.CommentPending:
|
||||
return v.isAdmin || v.owns(c)
|
||||
case models.CommentRejected:
|
||||
return v.isAdmin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// owns reports whether the viewer is the author of the comment.
|
||||
func (v commentViewer) owns(c *models.Comment) bool {
|
||||
if v.userID != nil && c.UserID != nil && *v.userID == *c.UserID {
|
||||
return true
|
||||
}
|
||||
if v.guestToken != "" && c.GuestToken != "" && v.guestToken == c.GuestToken {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CommentNode is a comment plus its rendered children, used by the template's
|
||||
// recursive comment_node block. Tr/UseGravatar/AvatarColor are propagated to
|
||||
// every node so the recursive template can render badges and avatars without
|
||||
// reaching back to the page-level data (inside a {{template}} invocation, $
|
||||
// binds to the node, not the page data).
|
||||
type CommentNode struct {
|
||||
Comment models.Comment
|
||||
Children []CommentNode
|
||||
Depth int
|
||||
RelTime string
|
||||
Tr map[string]string
|
||||
UseGravatar bool
|
||||
AvatarColor string
|
||||
}
|
||||
|
||||
// avatarPalette is the set of background colors used for text-initial avatars
|
||||
// when Gravatar is disabled.
|
||||
var avatarPalette = []string{
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#14b8a6", "#6366f1",
|
||||
}
|
||||
|
||||
// avatarColorFor returns a deterministic palette color for a comment ID.
|
||||
func avatarColorFor(id uint) string {
|
||||
if len(avatarPalette) == 0 {
|
||||
return "#3b82f6"
|
||||
}
|
||||
return avatarPalette[int(id)%len(avatarPalette)]
|
||||
}
|
||||
|
||||
// buildCommentTree filters comments by visibility and assembles them into a
|
||||
// nested tree ordered by creation time. tr and useGravatar are propagated to
|
||||
// every node for template rendering.
|
||||
func buildCommentTree(comments []models.Comment, viewer commentViewer, tr map[string]string, useGravatar bool) []CommentNode {
|
||||
visible := make([]models.Comment, 0, len(comments))
|
||||
for i := range comments {
|
||||
if viewer.canSee(&comments[i]) {
|
||||
visible = append(visible, comments[i])
|
||||
}
|
||||
}
|
||||
byParent := make(map[uint][]models.Comment)
|
||||
var roots []models.Comment
|
||||
for _, c := range visible {
|
||||
if c.ParentID == nil {
|
||||
roots = append(roots, c)
|
||||
} else {
|
||||
byParent[*c.ParentID] = append(byParent[*c.ParentID], c)
|
||||
}
|
||||
}
|
||||
nodes := make([]CommentNode, 0, len(roots))
|
||||
for _, r := range roots {
|
||||
nodes = append(nodes, buildCommentNode(r, byParent, 1, tr, useGravatar))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func buildCommentNode(c models.Comment, byParent map[uint][]models.Comment, depth int, tr map[string]string, useGravatar bool) CommentNode {
|
||||
node := CommentNode{
|
||||
Comment: c,
|
||||
Depth: depth,
|
||||
RelTime: relativeTime(c.CreatedAt),
|
||||
Tr: tr,
|
||||
UseGravatar: useGravatar,
|
||||
AvatarColor: avatarColorFor(c.ID),
|
||||
}
|
||||
for _, child := range byParent[c.ID] {
|
||||
node.Children = append(node.Children, buildCommentNode(child, byParent, depth+1, tr, useGravatar))
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// relativeTime returns a coarse human-readable age for a comment timestamp,
|
||||
// falling back to an absolute date for anything older than a day.
|
||||
func relativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
+47
-8
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -16,16 +18,38 @@ 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")
|
||||
siteFavicon, _ := c.Get("site_favicon")
|
||||
siteFaviconIsURL, _ := c.Get("site_favicon_is_url")
|
||||
siteLogoText, _ := c.Get("site_logo_text")
|
||||
siteHeaderText, _ := c.Get("site_header_text")
|
||||
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,
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"Username": username,
|
||||
"Lang": lang,
|
||||
"SwitchLang": switchLang,
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
"Tr": tr,
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"Username": username,
|
||||
"Lang": lang,
|
||||
"SwitchLang": switchLang,
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
"SiteSetting": siteSetting,
|
||||
"SiteLogo": siteLogo,
|
||||
"SiteLogoIsURL": siteLogoIsURL,
|
||||
"SiteFavicon": siteFavicon,
|
||||
"SiteFaviconIsURL": siteFaviconIsURL,
|
||||
"SiteLogoText": siteLogoText,
|
||||
"SiteHeaderText": siteHeaderText,
|
||||
"SiteHomeWelcome": siteHomeWelcome,
|
||||
"SiteHomeSubtitle": siteHomeSubtitle,
|
||||
"SiteFooterText": siteFooterText,
|
||||
"NavLinks": navLinks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +66,18 @@ func getTr(c *gin.Context) map[string]string {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// GetClientIP extracts the real client IP address, accounting for CDN/reverse proxy setups.
|
||||
// It checks X-Forwarded-For and X-Real-IP headers before falling back to c.ClientIP().
|
||||
func GetClientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i != -1 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xri := c.GetHeader("X-Real-IP"); xri != "" {
|
||||
return strings.TrimSpace(xri)
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
+396
-2
@@ -1,17 +1,411 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HomePage renders the public home page.
|
||||
func HomePage() gin.HandlerFunc {
|
||||
// publishedArticleOrder is the ordering used to list published articles:
|
||||
// pinned first, then by most recent publish/creation time.
|
||||
const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC"
|
||||
|
||||
// HomePage renders the public home page with the latest published articles.
|
||||
func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["page_home"]
|
||||
|
||||
// Get tag filter if present
|
||||
tagSlug := c.Query("tag")
|
||||
|
||||
// Build query
|
||||
query := db.Where("status = ?", models.ArticlePublished)
|
||||
|
||||
if tagSlug != "" {
|
||||
// Join with article_tags to filter by tag
|
||||
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
|
||||
// Get tag info for display
|
||||
var tag models.Tag
|
||||
if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil {
|
||||
data["FilterTag"] = tag
|
||||
}
|
||||
}
|
||||
|
||||
var articles []models.Article
|
||||
query.Preload("Tags").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(10).
|
||||
Find(&articles)
|
||||
|
||||
// Load all tags for sidebar
|
||||
var tags []models.Tag
|
||||
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
|
||||
|
||||
// Get comment counts for all articles
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
var commentCounts []CommentCount
|
||||
if len(articleIDs) > 0 {
|
||||
db.Model(&models.Comment{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("article_id IN ?", articleIDs).
|
||||
Where("status = ?", models.CommentApproved).
|
||||
Group("article_id").
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Add data to template
|
||||
data["Articles"] = articles
|
||||
data["Tags"] = tags
|
||||
data["CommentCounts"] = commentCountMap
|
||||
|
||||
c.HTML(http.StatusOK, "home", data)
|
||||
}
|
||||
}
|
||||
|
||||
// HomeArticlesAPI returns articles in JSON format for infinite scroll.
|
||||
func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page := 1
|
||||
if p, ok := c.GetQuery("page"); ok {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(p, "%d", &parsed); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 10
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Get tag filter if present
|
||||
tagSlug := c.Query("tag")
|
||||
|
||||
// Build query
|
||||
query := db.Where("status = ?", models.ArticlePublished)
|
||||
countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished)
|
||||
|
||||
if tagSlug != "" {
|
||||
// Join with article_tags to filter by tag
|
||||
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
|
||||
countQuery = countQuery.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
}
|
||||
|
||||
var articles []models.Article
|
||||
query.Preload("Tags").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&articles)
|
||||
|
||||
var total int64
|
||||
countQuery.Count(&total)
|
||||
|
||||
// Get comment counts for these articles
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
var commentCounts []CommentCount
|
||||
if len(articleIDs) > 0 {
|
||||
db.Model(&models.Comment{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("article_id IN ?", articleIDs).
|
||||
Where("status = ?", models.CommentApproved).
|
||||
Group("article_id").
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Build response with comment counts
|
||||
type ArticleResponse struct {
|
||||
models.Article
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
}
|
||||
|
||||
articlesWithCounts := make([]ArticleResponse, len(articles))
|
||||
for i, article := range articles {
|
||||
articlesWithCounts[i] = ArticleResponse{
|
||||
Article: article,
|
||||
CommentCount: commentCountMap[article.ID],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"articles": articlesWithCounts,
|
||||
"hasMore": int64(offset+len(articles)) < total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleDetail renders a single published article by its slug.
|
||||
func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
slug := c.Param("slug")
|
||||
|
||||
var article models.Article
|
||||
err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).
|
||||
Preload("Author").
|
||||
First(&article).Error
|
||||
if err != nil {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_not_found"]
|
||||
c.HTML(http.StatusNotFound, "article_not_found", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count; ignore errors so a view never breaks the page.
|
||||
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
|
||||
// Record unique article view asynchronously (doesn't block page response).
|
||||
go recordArticleView(db, article.ID, c)
|
||||
|
||||
// One-time flash notice (set by PostComment on success/pending). Reading
|
||||
// consumes the flash, so refreshing the page no longer re-shows it.
|
||||
notice := readCommentFlash(c)
|
||||
renderArticleDetail(c, db, &article, commentForm{}, "", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// renderArticleDetail renders the article page, including the comment section.
|
||||
// formErr refills the form with an error banner; notice is a one-time
|
||||
// success/pending banner (already consumed from the session by the caller).
|
||||
func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) {
|
||||
tr := getTr(c)
|
||||
|
||||
authorName := article.Author.Username
|
||||
if article.Author.DisplayName != "" {
|
||||
authorName = article.Author.DisplayName
|
||||
}
|
||||
|
||||
viewer := viewerFromContext(c)
|
||||
|
||||
var comments []models.Comment
|
||||
db.Where("article_id = ?", article.ID).Order("created_at ASC").Find(&comments)
|
||||
cfg := models.GetCommentConfig()
|
||||
useGravatar := cfg != nil && cfg.UseGravatar
|
||||
tree := buildCommentTree(comments, viewer, tr, useGravatar)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = article.Title
|
||||
data["Article"] = article
|
||||
data["AuthorName"] = authorName
|
||||
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
|
||||
data["UpdatedAt"] = formatUpdateTime(article.UpdatedAt)
|
||||
data["Comments"] = tree
|
||||
data["CommentConfig"] = models.GetCommentConfig()
|
||||
data["CommentForm"] = form
|
||||
data["CommentError"] = formErr
|
||||
data["CommentNotice"] = notice
|
||||
data["MaxCommentLength"] = MaxCommentLength
|
||||
c.HTML(http.StatusOK, "article", data)
|
||||
}
|
||||
|
||||
// formatPublishTime returns the publication time as a readable string, falling
|
||||
// back to the creation time when the publish timestamp is unset.
|
||||
func formatPublishTime(publishedAt *time.Time) string {
|
||||
if publishedAt != nil {
|
||||
return publishedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatUpdateTime returns the last update time as a readable string.
|
||||
func formatUpdateTime(updatedAt time.Time) string {
|
||||
return updatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// commentFlashKey is the session key for the one-time comment notice.
|
||||
const commentFlashKey = "comment_flash"
|
||||
|
||||
// setCommentFlash stores a one-time comment notice in the session so the
|
||||
// following GET /article/:slug (after the POST/redirect) can show it once and
|
||||
// never again on refresh.
|
||||
func setCommentFlash(c *gin.Context, value string) {
|
||||
session := sessions.Default(c)
|
||||
session.Set(commentFlashKey, value)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
// readCommentFlash returns and clears the one-time comment notice, if any.
|
||||
func readCommentFlash(c *gin.Context) string {
|
||||
session := sessions.Default(c)
|
||||
v, ok := session.Get(commentFlashKey).(string)
|
||||
if ok && v != "" {
|
||||
session.Delete(commentFlashKey)
|
||||
session.Save()
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// recordArticleView records a unique article view in the database.
|
||||
// This function is designed to be called asynchronously (via goroutine) to avoid
|
||||
// blocking the page response. It checks for existing records to ensure each
|
||||
// user/IP combination only records one view per article.
|
||||
func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Extract user ID from session if logged in
|
||||
var userID *uint
|
||||
if uid := session.Get("user_id"); uid != nil {
|
||||
switch v := uid.(type) {
|
||||
case uint:
|
||||
userID = &v
|
||||
case int:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case int64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case float64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP and User-Agent
|
||||
ip := GetClientIP(c)
|
||||
userAgent := c.Request.UserAgent()
|
||||
isBot := models.IsBot(userAgent)
|
||||
|
||||
// Check if this view already exists (deduplication)
|
||||
var count int64
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Where("article_id = ? AND ip = ?", articleID, ip)
|
||||
|
||||
if userID != nil {
|
||||
query = query.Where("user_id = ?", *userID)
|
||||
} else {
|
||||
query = query.Where("user_id IS NULL")
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
// Silently fail - don't break the user experience
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Already recorded
|
||||
return
|
||||
}
|
||||
|
||||
// Create new view record using INSERT IGNORE pattern
|
||||
view := models.ArticleView{
|
||||
ArticleID: articleID,
|
||||
UserID: userID,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
IsBot: isBot,
|
||||
}
|
||||
|
||||
// Create the view record (BeforeCreate hook in model handles deduplication)
|
||||
db.Create(&view)
|
||||
// Ignore errors - this is a best-effort tracking system
|
||||
}
|
||||
|
||||
// SearchPage handles article search by keyword.
|
||||
func SearchPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
|
||||
keyword := strings.TrimSpace(c.Query("q"))
|
||||
data["SearchKeyword"] = keyword
|
||||
data["Title"] = tr["search_title"]
|
||||
|
||||
if keyword == "" {
|
||||
data["Articles"] = []models.Article{}
|
||||
data["Tags"] = []models.Tag{}
|
||||
data["CommentCounts"] = make(map[uint]int64)
|
||||
c.HTML(http.StatusOK, "search", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Search in title, summary, and content
|
||||
searchPattern := "%" + keyword + "%"
|
||||
var articles []models.Article
|
||||
db.Where("status = ?", models.ArticlePublished).
|
||||
Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern).
|
||||
Preload("Tags").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(50). // Limit search results
|
||||
Find(&articles)
|
||||
|
||||
// Load all tags for sidebar
|
||||
var tags []models.Tag
|
||||
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
|
||||
|
||||
// Get comment counts
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
var commentCounts []CommentCount
|
||||
if len(articleIDs) > 0 {
|
||||
db.Model(&models.Comment{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("article_id IN ?", articleIDs).
|
||||
Where("status = ?", models.CommentApproved).
|
||||
Group("article_id").
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
data["Articles"] = articles
|
||||
data["Tags"] = tags
|
||||
data["CommentCounts"] = commentCountMap
|
||||
|
||||
c.HTML(http.StatusOK, "search", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// MyArticlesPage renders the logged-in user's article management page.
|
||||
func MyArticlesPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var articles []models.Article
|
||||
db.Where("author_id = ?", userID).Order("created_at DESC").Find(&articles)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["my_articles_title"]
|
||||
data["Articles"] = articles
|
||||
c.HTML(http.StatusOK, "my_articles", data)
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreatePage renders the article creation form for regular users.
|
||||
func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
token := randomToken()
|
||||
session := sessions.Default(c)
|
||||
session.Set("pending_token", token)
|
||||
session.Save()
|
||||
|
||||
renderMyArticleForm(c, db, articleForm{
|
||||
Action: "/my/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
SessionToken: token,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreate handles the POST request to create a new article for regular users.
|
||||
func MyArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return ArticleCreate(db) // Reuse the same logic
|
||||
}
|
||||
|
||||
// MyArticleEditPage renders the article edit form for the logged-in user's own articles.
|
||||
func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
return
|
||||
}
|
||||
|
||||
renderMyArticleForm(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,
|
||||
PublishedAt: formatPublishedAt(article.PublishedAt),
|
||||
Action: "/my/articles/" + id + "/edit",
|
||||
TitleText: tr["article_edit_title"],
|
||||
ArticleID: article.ID,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleUpdate handles the POST request to update the user's own article.
|
||||
func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
return
|
||||
}
|
||||
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/my/articles/" + id + "/edit"
|
||||
f.TitleText = tr["article_edit_title"]
|
||||
f.ArticleID = article.ID
|
||||
|
||||
if f.Title == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_title_required"])
|
||||
return
|
||||
}
|
||||
if f.Content == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_content_required"])
|
||||
return
|
||||
}
|
||||
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(article.ID)
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
|
||||
var publishedAt *time.Time
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else {
|
||||
// Stamp the publish time the first time an article is published.
|
||||
wasPublished := article.Status == models.ArticlePublished
|
||||
publishedAt = 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,
|
||||
"is_top": f.IsTop,
|
||||
"published_at": publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Model(&article).Updates(updates).Error; err != nil {
|
||||
renderMyArticleForm(c, db, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleDelete soft-deletes the user's own article.
|
||||
func MyArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
db.Where("id = ? AND author_id = ?", id, userID).Delete(&models.Article{})
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
}
|
||||
}
|
||||
|
||||
// renderMyArticleForm renders the article form for regular users.
|
||||
func renderMyArticleForm(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, "my_article_form", data)
|
||||
}
|
||||
+42
-11
@@ -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)
|
||||
}
|
||||
@@ -64,9 +72,9 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// --- Text fields ---
|
||||
if v := c.PostForm("display_name"); v != "" {
|
||||
user.DisplayName = v
|
||||
}
|
||||
// Allow empty display_name (user can clear it to fall back to username)
|
||||
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
|
||||
|
||||
if v := c.PostForm("gender"); v != "" {
|
||||
user.Gender = v
|
||||
}
|
||||
@@ -84,12 +92,23 @@ 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")
|
||||
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
||||
@@ -173,12 +192,24 @@ 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)
|
||||
if err != nil {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RSS 2.0 XML structure definitions.
|
||||
|
||||
// RSS is the root element of an RSS 2.0 feed.
|
||||
type RSS struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel *Channel `xml:"channel"`
|
||||
}
|
||||
|
||||
// Channel represents the RSS channel containing feed metadata and items.
|
||||
type Channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
LastBuildDate string `xml:"lastBuildDate,omitempty"`
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
// Item represents a single article in the RSS feed.
|
||||
type Item struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Author string `xml:"author,omitempty"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
GUID string `xml:"guid"`
|
||||
}
|
||||
|
||||
// RSSFeed generates an RSS 2.0 feed of the latest published articles.
|
||||
func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Determine the current language for site metadata.
|
||||
lang, exists := c.Get("lang")
|
||||
if !exists {
|
||||
lang = "en"
|
||||
}
|
||||
langStr := lang.(string)
|
||||
|
||||
// Get site settings for feed metadata.
|
||||
siteSetting := &models.SiteSetting{}
|
||||
db.First(siteSetting)
|
||||
|
||||
// Construct the base URL from the request.
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
|
||||
// Get the latest 20 published articles.
|
||||
var articles []models.Article
|
||||
db.Where("status = ?", models.ArticlePublished).
|
||||
Preload("Author").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(20).
|
||||
Find(&articles)
|
||||
|
||||
// Build channel metadata.
|
||||
channel := &Channel{
|
||||
Title: siteSetting.LogoText(langStr),
|
||||
Link: baseURL,
|
||||
Description: siteSetting.HomeSubtitle(langStr),
|
||||
Language: getRSSLanguage(langStr),
|
||||
Items: make([]Item, 0, len(articles)),
|
||||
}
|
||||
|
||||
// Set lastBuildDate to the most recent article's publish date.
|
||||
if len(articles) > 0 && articles[0].PublishedAt != nil {
|
||||
channel.LastBuildDate = formatRSSTime(*articles[0].PublishedAt)
|
||||
}
|
||||
|
||||
// Convert articles to RSS items.
|
||||
for _, article := range articles {
|
||||
item := Item{
|
||||
Title: article.Title,
|
||||
Link: fmt.Sprintf("%s/article/%s", baseURL, article.Slug),
|
||||
Description: getArticleDescription(&article),
|
||||
PubDate: formatRSSTime(getArticlePubDate(&article)),
|
||||
GUID: fmt.Sprintf("%s/article/%s", baseURL, article.Slug),
|
||||
}
|
||||
|
||||
// Add author information.
|
||||
if article.Author.DisplayName != "" {
|
||||
item.Author = article.Author.DisplayName
|
||||
} else {
|
||||
item.Author = article.Author.Username
|
||||
}
|
||||
|
||||
channel.Items = append(channel.Items, item)
|
||||
}
|
||||
|
||||
// Build the RSS feed.
|
||||
feed := &RSS{
|
||||
Version: "2.0",
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
// Set the correct content type and return XML.
|
||||
c.Header("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
c.XML(http.StatusOK, feed)
|
||||
}
|
||||
}
|
||||
|
||||
// getRSSLanguage converts the internal language code to RSS language format.
|
||||
func getRSSLanguage(lang string) string {
|
||||
switch lang {
|
||||
case "zh":
|
||||
return "zh-CN"
|
||||
case "en":
|
||||
return "en-US"
|
||||
default:
|
||||
return "en-US"
|
||||
}
|
||||
}
|
||||
|
||||
// formatRSSTime formats a time.Time to RFC1123Z format required by RSS 2.0.
|
||||
func formatRSSTime(t time.Time) string {
|
||||
return t.Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
// getArticlePubDate returns the article's publish date, falling back to created date.
|
||||
func getArticlePubDate(article *models.Article) time.Time {
|
||||
if article.PublishedAt != nil {
|
||||
return *article.PublishedAt
|
||||
}
|
||||
return article.CreatedAt
|
||||
}
|
||||
|
||||
// getArticleDescription returns the article description for RSS.
|
||||
// Prefers the summary field; falls back to truncated content.
|
||||
func getArticleDescription(article *models.Article) string {
|
||||
if article.Summary != "" {
|
||||
return html.EscapeString(article.Summary)
|
||||
}
|
||||
|
||||
// Strip HTML tags and truncate content to 200 characters.
|
||||
content := stripHTMLTags(article.Content)
|
||||
if len(content) > 200 {
|
||||
content = content[:200] + "..."
|
||||
}
|
||||
return html.EscapeString(content)
|
||||
}
|
||||
|
||||
// stripHTMLTags removes HTML tags from a string (basic implementation).
|
||||
func stripHTMLTags(s string) string {
|
||||
// Remove HTML tags by finding < and > pairs.
|
||||
var result strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces and trim.
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return cleaned
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
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. It
|
||||
// defends against int/uint/int64/float64 storage in the session.
|
||||
func userIDFromSession(c *gin.Context) uint {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case int:
|
||||
return uint(v)
|
||||
case int64:
|
||||
return uint(v)
|
||||
case float64:
|
||||
return uint(v)
|
||||
}
|
||||
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
|
||||
// Pre-compute derived values so the template never invokes methods on
|
||||
// an interface{}-wrapped struct (which Go templates cannot resolve).
|
||||
data["SiteLogoIsURL"] = s.LogoIsURL()
|
||||
data["SiteFaviconIsURL"] = s.FaviconIsURL()
|
||||
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.HomeWelcomeZh = strings.TrimSpace(c.PostForm("home_welcome_zh"))
|
||||
s.HomeWelcomeEn = strings.TrimSpace(c.PostForm("home_welcome_en"))
|
||||
s.HomeSubtitleZh = strings.TrimSpace(c.PostForm("home_subtitle_zh"))
|
||||
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
|
||||
// uploaded file, so admins can set either a local file or an external link.
|
||||
if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" {
|
||||
s.Favicon = faviconURL
|
||||
} else if file, header, err := c.Request.FormFile("favicon"); 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 favicon (skip external URLs).
|
||||
if s.Favicon != "" && !s.FaviconIsURL() {
|
||||
os.Remove(filepath.Join(logoDir, s.Favicon))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("favicon%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.Favicon = savedName
|
||||
}
|
||||
|
||||
// Remove favicon entirely if requested.
|
||||
if c.PostForm("favicon_clear") == "1" {
|
||||
if s.Favicon != "" && !s.FaviconIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
|
||||
}
|
||||
s.Favicon = ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ---------------- Comment settings ----------------
|
||||
|
||||
// CommentSettingsPage renders the comment policy form.
|
||||
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var cc models.CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err != nil {
|
||||
cc = *models.GetCommentConfig()
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["comment_settings_title"]
|
||||
data["CommentConfig"] = cc
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_comment", data)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentSettingsSave persists the comment policy toggles and refreshes the
|
||||
// in-memory cache so subsequent requests see the change.
|
||||
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cc models.CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err != nil {
|
||||
cc = models.CommentConfig{ID: 1}
|
||||
}
|
||||
cc.Enabled = c.PostForm("enabled") == "1"
|
||||
cc.AllowGuest = c.PostForm("allow_guest") == "1"
|
||||
cc.GuestRequireApproval = c.PostForm("guest_require_approval") == "1"
|
||||
cc.UseGravatar = c.PostForm("use_gravatar") == "1"
|
||||
cc.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&cc)
|
||||
models.RefreshConfigCache(db)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
+640
@@ -28,6 +28,7 @@ var translations = map[Lang]map[string]string{
|
||||
"home_sign_in": "Sign In",
|
||||
"home_to_dash": "Dashboard",
|
||||
"home_latest": "Latest Posts",
|
||||
"home_read_more": "Read more",
|
||||
"home_post1_tit": "Getting Started",
|
||||
"home_post1_dsc": "Welcome to your new blog! This is a placeholder post. Start writing to see your content here.",
|
||||
"home_post2_tit": "Hello World",
|
||||
@@ -45,6 +46,38 @@ var translations = map[Lang]map[string]string{
|
||||
"login_submit": "Sign In",
|
||||
"login_error": "Invalid username or password.",
|
||||
"login_required": "Please fill in all fields.",
|
||||
"login_no_account": "Don't have an account?",
|
||||
"login_register_link": "Register",
|
||||
|
||||
// Register page
|
||||
"page_register": "Register",
|
||||
"register_title": "Create Account",
|
||||
"register_username": "Username",
|
||||
"register_ph_username": "Choose a username",
|
||||
"register_username_hint": "3-32 characters, letters, numbers, underscore and dash only",
|
||||
"register_display_name": "Display Name",
|
||||
"register_ph_display_name": "Your display name",
|
||||
"register_display_name_hint": "Optional, defaults to username if empty",
|
||||
"register_email": "Email",
|
||||
"register_ph_email": "Your email address",
|
||||
"register_email_hint": "Optional, used for Gravatar avatar",
|
||||
"register_password": "Password",
|
||||
"register_ph_password": "Choose a password",
|
||||
"register_password_hint": "At least 6 characters",
|
||||
"register_confirm_password": "Confirm Password",
|
||||
"register_ph_confirm_password": "Re-enter your password",
|
||||
"register_submit": "Register",
|
||||
"register_have_account": "Already have an account?",
|
||||
"register_login_link": "Sign In",
|
||||
"register_required": "Username and password are required.",
|
||||
"register_username_length": "Username must be 3-32 characters.",
|
||||
"register_password_length": "Password must be at least 6 characters.",
|
||||
"register_password_mismatch": "Passwords do not match.",
|
||||
"register_error": "Registration failed. Please try again.",
|
||||
|
||||
// Settings
|
||||
"settings_allow_registration": "Allow user registration",
|
||||
"settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page",
|
||||
|
||||
// Dashboard
|
||||
"dash_title": "Dashboard",
|
||||
@@ -52,8 +85,10 @@ var translations = map[Lang]map[string]string{
|
||||
"dash_posts": "Posts",
|
||||
"dash_pages": "Pages",
|
||||
"dash_users": "Users",
|
||||
"dash_published": "Published",
|
||||
"dash_mgmt_title": "Blog Management",
|
||||
"dash_mgmt_desc": "Post management, categories, and settings will appear here in future updates.",
|
||||
"dash_new_article": "New Article",
|
||||
"dash_page_title": "Dashboard",
|
||||
|
||||
// Footer
|
||||
@@ -69,6 +104,7 @@ var translations = map[Lang]map[string]string{
|
||||
// Profile dropdown & page
|
||||
"profile": "Profile",
|
||||
"admin_panel": "Admin Panel",
|
||||
"my_articles": "My Articles",
|
||||
"profile_title": "Edit Profile",
|
||||
"profile_avatar": "Avatar",
|
||||
"profile_change_avatar": "Change Avatar",
|
||||
@@ -82,6 +118,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",
|
||||
@@ -96,23 +135,304 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// Article creation
|
||||
"article_create_title": "Create Article",
|
||||
"article_field_title": "Title",
|
||||
"article_title": "Title",
|
||||
"article_field_slug": "Slug",
|
||||
"article_slug": "Slug",
|
||||
"article_slug_hint": "Auto-generated from title if empty",
|
||||
"article_slug_help": "Auto-generated from title if empty",
|
||||
"article_field_summary": "Summary",
|
||||
"article_summary": "Summary",
|
||||
"article_field_content": "Content",
|
||||
"article_content": "Content",
|
||||
"article_field_cover": "Cover Image URL",
|
||||
"article_cover": "Cover Image URL",
|
||||
"article_cover_help": "Optional cover image URL",
|
||||
"article_field_status": "Status",
|
||||
"article_status": "Status",
|
||||
"article_draft": "Draft",
|
||||
"article_published": "Published",
|
||||
"article_field_is_top": "Pin to top",
|
||||
"article_is_top": "Pin to top",
|
||||
"article_published_at": "Published Time",
|
||||
"article_published_at_hint": "Leave blank to auto-set on publish",
|
||||
"article_save_draft": "Save Draft",
|
||||
"article_save": "Save",
|
||||
"article_cancel": "Cancel",
|
||||
"article_publish": "Publish",
|
||||
"article_created": "Article created successfully.",
|
||||
"article_error": "Failed to create article.",
|
||||
"article_new": "New Article",
|
||||
"article_title_required": "Title is required.",
|
||||
"article_content_required": "Content is required.",
|
||||
"article_back_home": "Back to Home",
|
||||
"article_not_found": "Article not found.",
|
||||
"article_attachments": "Attachments",
|
||||
"article_upload": "Upload",
|
||||
"article_att_name": "File",
|
||||
"article_att_size": "Size",
|
||||
"article_att_insert": "Insert",
|
||||
"article_att_set_cover": "Set as Cover",
|
||||
"article_att_cover_set": "Cover URL set.",
|
||||
"article_att_pick": "Please choose a file.",
|
||||
"article_att_uploading": "Uploading...",
|
||||
"article_att_error": "Upload failed. Please try again.",
|
||||
"article_att_delete_confirm": "Delete this attachment?",
|
||||
|
||||
// Article management
|
||||
"article_list_title": "Articles",
|
||||
"my_articles_title": "My 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",
|
||||
"article_last_updated": "Last Updated",
|
||||
|
||||
// Tags
|
||||
"tags_title": "Tags",
|
||||
"article_tags": "Tags",
|
||||
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial",
|
||||
"tag_filter": "Filter by tag",
|
||||
"tag_all": "All",
|
||||
|
||||
// Search
|
||||
"search_placeholder": "Search articles...",
|
||||
"search_title": "Search Results",
|
||||
"search_results_for": "Search results for",
|
||||
"search_no_results": "No articles found matching your search.",
|
||||
"search_keyword": "Keyword",
|
||||
|
||||
// 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_navlinks_title": "Navigation Links",
|
||||
"settings_navlinks_desc": "Manage custom links in the header navigation.",
|
||||
"navlinks_add": "Add Link",
|
||||
"navlinks_title_zh": "Link Text (Chinese)",
|
||||
"navlinks_title_en": "Link Text (English)",
|
||||
"navlinks_url": "URL",
|
||||
"navlinks_url_hint": "Can be relative (/about) or absolute (https://example.com)",
|
||||
"navlinks_open_new": "Open in new window",
|
||||
"navlinks_enabled": "Enabled",
|
||||
"navlinks_sort": "Sort Order",
|
||||
"navlinks_edit": "Edit",
|
||||
"navlinks_delete": "Delete",
|
||||
"navlinks_confirm_delete": "Are you sure you want to delete this link?",
|
||||
"navlinks_no_links": "No navigation links configured yet.",
|
||||
"navlinks_save": "Save",
|
||||
"navlinks_cancel": "Cancel",
|
||||
"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_favicon": "Favicon",
|
||||
"settings_favicon_url": "Favicon URL (external link)",
|
||||
"settings_favicon_current": "Current favicon",
|
||||
"settings_favicon_hint": "Recommended: .ico, .png or .svg format, 32x32 or 16x16 pixels",
|
||||
"settings_favicon_clear": "Remove current favicon",
|
||||
"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_home_welcome_zh": "Home Welcome Heading (Chinese)",
|
||||
"settings_home_welcome_en": "Home Welcome Heading (English)",
|
||||
"settings_home_subtitle_zh": "Home Subtitle (Chinese)",
|
||||
"settings_home_subtitle_en": "Home Subtitle (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",
|
||||
|
||||
// Comments
|
||||
"comments_title": "Comments",
|
||||
"comments_count": "%d Comments",
|
||||
"comments_empty": "No comments yet. Be the first to comment.",
|
||||
"comments_reply": "Reply",
|
||||
"comments_cancel_reply": "Cancel reply",
|
||||
"comments_replying_to": "Replying to %s",
|
||||
"comments_name": "Name",
|
||||
"comments_email": "Email",
|
||||
"comments_email_hint": "Used for your Gravatar avatar; never shown publicly.",
|
||||
"comments_website": "Website (optional)",
|
||||
"comments_content": "Comment",
|
||||
"comments_content_ph": "Markdown is supported...",
|
||||
"comments_private": "Private comment",
|
||||
"comments_private_hint": "Only you and the administrator can see this.",
|
||||
"comments_preview": "Preview",
|
||||
"comments_edit": "Edit",
|
||||
"comments_emoji": "Emoji",
|
||||
"comments_markdown_help": "Markdown help",
|
||||
"comments_submit": "Post Comment",
|
||||
"comments_posted": "Comment posted.",
|
||||
"comments_pending_notice": "Your comment is awaiting moderation.",
|
||||
"comments_disabled": "Comments are disabled.",
|
||||
"comments_guests_disabled": "You must sign in to comment.",
|
||||
"comments_required_name": "Name is required.",
|
||||
"comments_required_email": "Email is required.",
|
||||
"comments_required_content":"Comment is required.",
|
||||
"comments_invalid_email": "Please enter a valid email address.",
|
||||
"comments_invalid_url": "Please enter a valid URL.",
|
||||
"comments_too_long": "Comment is too long (max %d characters).",
|
||||
"comments_pending": "Pending",
|
||||
"comments_approved": "Approved",
|
||||
"comments_rejected": "Rejected",
|
||||
"comments_private_badge": "Private",
|
||||
"comments_markdown_bold": "**bold**",
|
||||
"comments_markdown_italic": "*italic*",
|
||||
"comments_markdown_code": "`code`",
|
||||
"comments_markdown_link": "[text](url)",
|
||||
"comments_markdown_quote": "> quote",
|
||||
|
||||
// Admin: comments
|
||||
"admin_comments": "Comments",
|
||||
"comment_manage": "Manage Comments",
|
||||
"admin_comments_title": "Comments",
|
||||
"comment_status_pending": "Pending",
|
||||
"comment_status_approved": "Approved",
|
||||
"comment_status_rejected": "Rejected",
|
||||
"comment_status_all": "All",
|
||||
"comment_approve": "Approve",
|
||||
"comment_reject": "Reject",
|
||||
"comment_delete": "Delete",
|
||||
"comment_delete_confirm": "Delete this comment?",
|
||||
"comment_col_author": "Author",
|
||||
"comment_col_article": "Article",
|
||||
"comment_col_content": "Content",
|
||||
"comment_col_status": "Status",
|
||||
"comment_col_time": "Time",
|
||||
"comment_col_actions": "Actions",
|
||||
"comment_empty": "No comments.",
|
||||
"comment_approved": "Comment approved.",
|
||||
"comment_rejected": "Comment rejected.",
|
||||
"comment_deleted": "Comment deleted.",
|
||||
|
||||
// Admin: comment settings
|
||||
"comment_settings_title": "Comment Settings",
|
||||
"comment_settings_desc": "Control whether comments are enabled and how they are moderated.",
|
||||
"comment_settings_enabled": "Enable comments",
|
||||
"comment_settings_allow_guest": "Allow anonymous comments",
|
||||
"comment_settings_guest_approval": "Require approval for guest comments",
|
||||
"comment_settings_use_gravatar": "Use Gravatar avatars",
|
||||
"comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.",
|
||||
|
||||
// Admin: user management
|
||||
"admin_users": "Users",
|
||||
"admin_users_title": "Users",
|
||||
"user_list_title": "User Management",
|
||||
"user_new": "New User",
|
||||
"user_create_title": "Create User",
|
||||
"user_edit_title": "Edit User",
|
||||
"user_col_username": "Username",
|
||||
"user_col_display": "Display Name",
|
||||
"user_col_email": "Email",
|
||||
"user_col_role": "Role",
|
||||
"user_col_status": "Status",
|
||||
"user_col_created": "Created",
|
||||
"user_col_actions": "Actions",
|
||||
"user_username": "Username",
|
||||
"user_password": "Password",
|
||||
"user_password_hint": "Leave blank to keep the current password.",
|
||||
"user_display_name": "Display Name",
|
||||
"user_email": "Email",
|
||||
"user_gender": "Gender",
|
||||
"user_birthday": "Birthday",
|
||||
"user_role": "Role",
|
||||
"user_status": "Status",
|
||||
"role_admin": "Admin",
|
||||
"role_author": "Author",
|
||||
"status_normal": "Normal",
|
||||
"status_disabled": "Disabled",
|
||||
"status_locked": "Locked",
|
||||
"status_unactivated": "Unactivated",
|
||||
"user_created": "User created.",
|
||||
"user_updated": "User updated.",
|
||||
"user_deleted": "User deleted.",
|
||||
"user_delete_confirm": "Delete this user?",
|
||||
"user_empty": "No users.",
|
||||
"user_username_required": "Username is required.",
|
||||
"user_password_required": "Password is required.",
|
||||
"user_username_exists": "Username already exists.",
|
||||
"user_cannot_delete_self": "You cannot delete your own account.",
|
||||
"user_cannot_disable_self": "You cannot disable or lock your own account.",
|
||||
"user_cannot_remove_last_admin": "Cannot remove the last administrator.",
|
||||
"dash_user_mgmt": "Manage Users",
|
||||
|
||||
// Analytics
|
||||
"analytics_views_title": "Reading Analytics",
|
||||
"analytics_views_desc": "Track article views, identify unique visitors and detect bots.",
|
||||
"analytics_stats_total": "Total Views",
|
||||
"analytics_stats_human_views": "Human Views",
|
||||
"analytics_stats_bot_views": "Bot Views",
|
||||
"analytics_stats_unique_ips": "Unique IPs",
|
||||
"analytics_stats_unique_users": "Unique Users",
|
||||
"analytics_article_stats": "Article Statistics (Top 20)",
|
||||
"analytics_col_article": "Article",
|
||||
"analytics_col_total_views": "Total Views",
|
||||
"analytics_col_human_views": "Human",
|
||||
"analytics_col_bot_views": "Bots",
|
||||
"analytics_col_unique_ips": "Unique IPs",
|
||||
"analytics_col_time": "Time",
|
||||
"analytics_col_user": "User",
|
||||
"analytics_col_ip": "IP Address",
|
||||
"analytics_col_user_agent": "User Agent",
|
||||
"analytics_col_is_bot": "Type",
|
||||
"analytics_filters": "Filters",
|
||||
"analytics_filter_article": "Article Title",
|
||||
"analytics_filter_article_placeholder": "Search article title",
|
||||
"analytics_filter_ip": "IP Address",
|
||||
"analytics_filter_ip_placeholder": "Search by IP",
|
||||
"analytics_filter_show_bots": "Show Bots",
|
||||
"analytics_include_bots": "Include bot traffic",
|
||||
"analytics_apply_filters": "Apply Filters",
|
||||
"analytics_recent_views": "Recent Views",
|
||||
"analytics_bot": "Bot",
|
||||
"analytics_human": "Human",
|
||||
"analytics_anonymous": "Anonymous",
|
||||
"analytics_article_deleted": "(Deleted)",
|
||||
"analytics_no_data": "No data available.",
|
||||
"analytics_no_views": "No view records found.",
|
||||
"analytics_showing": "Showing",
|
||||
"analytics_of": "of",
|
||||
"analytics_load_more": "Load More",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -128,6 +448,7 @@ var translations = map[Lang]map[string]string{
|
||||
"home_sign_in": "登录",
|
||||
"home_to_dash": "后台管理",
|
||||
"home_latest": "最新文章",
|
||||
"home_read_more": "阅读全文",
|
||||
"home_post1_tit": "入门指南",
|
||||
"home_post1_dsc": "欢迎来到你的新博客!这是一篇占位文章。开始写作后,你的内容将显示在这里。",
|
||||
"home_post2_tit": "你好,世界",
|
||||
@@ -145,6 +466,38 @@ var translations = map[Lang]map[string]string{
|
||||
"login_submit": "登录",
|
||||
"login_error": "用户名或密码错误。",
|
||||
"login_required": "请填写所有字段。",
|
||||
"login_no_account": "还没有账号?",
|
||||
"login_register_link": "注册",
|
||||
|
||||
// 注册页
|
||||
"page_register": "注册",
|
||||
"register_title": "创建账号",
|
||||
"register_username": "用户名",
|
||||
"register_ph_username": "请输入用户名",
|
||||
"register_username_hint": "3-32个字符,仅限字母、数字、下划线和连字符",
|
||||
"register_display_name": "显示名称",
|
||||
"register_ph_display_name": "请输入显示名称",
|
||||
"register_display_name_hint": "可选,留空则使用用户名",
|
||||
"register_email": "邮箱",
|
||||
"register_ph_email": "请输入邮箱地址",
|
||||
"register_email_hint": "可选,用于显示 Gravatar 头像",
|
||||
"register_password": "密码",
|
||||
"register_ph_password": "请输入密码",
|
||||
"register_password_hint": "至少6个字符",
|
||||
"register_confirm_password": "确认密码",
|
||||
"register_ph_confirm_password": "请再次输入密码",
|
||||
"register_submit": "注册",
|
||||
"register_have_account": "已有账号?",
|
||||
"register_login_link": "登录",
|
||||
"register_required": "用户名和密码不能为空。",
|
||||
"register_username_length": "用户名必须是3-32个字符。",
|
||||
"register_password_length": "密码至少需要6个字符。",
|
||||
"register_password_mismatch": "两次输入的密码不一致。",
|
||||
"register_error": "注册失败,请重试。",
|
||||
|
||||
// 平台设置
|
||||
"settings_allow_registration": "允许用户注册",
|
||||
"settings_allow_registration_hint": "启用后,访客可以从登录页面创建自己的账号",
|
||||
|
||||
// 后台
|
||||
"dash_title": "后台管理",
|
||||
@@ -152,8 +505,10 @@ var translations = map[Lang]map[string]string{
|
||||
"dash_posts": "文章",
|
||||
"dash_pages": "页面",
|
||||
"dash_users": "用户",
|
||||
"dash_published": "已发布",
|
||||
"dash_mgmt_title": "博客管理",
|
||||
"dash_mgmt_desc": "文章管理、分类和设置将在后续版本中添加。",
|
||||
"dash_new_article": "新建文章",
|
||||
"dash_page_title": "后台管理",
|
||||
|
||||
// 页脚
|
||||
@@ -167,6 +522,7 @@ var translations = map[Lang]map[string]string{
|
||||
// 个人中心下拉菜单 & 页面
|
||||
"profile": "个人信息",
|
||||
"admin_panel": "后台管理",
|
||||
"my_articles": "我的文章",
|
||||
"profile_title": "编辑个人信息",
|
||||
"profile_avatar": "头像",
|
||||
"profile_change_avatar": "更换头像",
|
||||
@@ -180,6 +536,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": "其他",
|
||||
@@ -194,23 +553,304 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 文章创建
|
||||
"article_create_title": "新建文章",
|
||||
"article_field_title": "标题",
|
||||
"article_title": "标题",
|
||||
"article_field_slug": "Slug",
|
||||
"article_slug": "Slug",
|
||||
"article_slug_hint": "留空则从标题自动生成",
|
||||
"article_slug_help": "留空则从标题自动生成",
|
||||
"article_field_summary": "摘要",
|
||||
"article_summary": "摘要",
|
||||
"article_field_content": "正文",
|
||||
"article_content": "正文",
|
||||
"article_field_cover": "封面图 URL",
|
||||
"article_cover": "封面图 URL",
|
||||
"article_cover_help": "可选的封面图片链接",
|
||||
"article_field_status": "状态",
|
||||
"article_status": "状态",
|
||||
"article_draft": "草稿",
|
||||
"article_published": "已发布",
|
||||
"article_field_is_top": "置顶",
|
||||
"article_is_top": "置顶",
|
||||
"article_published_at": "发布时间",
|
||||
"article_published_at_hint": "留空则在发布时自动设置",
|
||||
"article_save_draft": "保存草稿",
|
||||
"article_save": "保存",
|
||||
"article_cancel": "取消",
|
||||
"article_publish": "发布",
|
||||
"article_created": "文章创建成功。",
|
||||
"article_error": "创建文章失败。",
|
||||
"article_new": "新建文章",
|
||||
"article_title_required": "标题不能为空。",
|
||||
"article_content_required": "正文不能为空。",
|
||||
"article_back_home": "返回首页",
|
||||
"article_not_found": "文章不存在。",
|
||||
"article_attachments": "附件",
|
||||
"article_upload": "上传",
|
||||
"article_att_name": "文件",
|
||||
"article_att_size": "大小",
|
||||
"article_att_insert": "插入正文",
|
||||
"article_att_set_cover": "设为封面",
|
||||
"article_att_cover_set": "已设为封面。",
|
||||
"article_att_pick": "请先选择文件。",
|
||||
"article_att_uploading": "上传中...",
|
||||
"article_att_error": "上传失败,请重试。",
|
||||
"article_att_delete_confirm": "删除该附件?",
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
"my_articles_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": "操作",
|
||||
"article_last_updated": "最后更新",
|
||||
|
||||
// 标签
|
||||
"tags_title": "标签",
|
||||
"article_tags": "标签",
|
||||
"article_tags_hint": "逗号分隔的标签名称,例如:golang, web, 教程",
|
||||
"tag_filter": "按标签筛选",
|
||||
"tag_all": "全部",
|
||||
|
||||
// 搜索
|
||||
"search_placeholder": "搜索文章...",
|
||||
"search_title": "搜索结果",
|
||||
"search_results_for": "搜索结果",
|
||||
"search_no_results": "未找到匹配的文章。",
|
||||
"search_keyword": "关键词",
|
||||
|
||||
// 平台设置
|
||||
"settings_nav": "平台设置",
|
||||
"settings_saved": "设置已保存。",
|
||||
"settings_site_title": "站点设置",
|
||||
"settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。",
|
||||
"settings_navlinks_title": "导航链接",
|
||||
"settings_navlinks_desc": "管理顶部导航栏的自定义链接。",
|
||||
"navlinks_add": "添加链接",
|
||||
"navlinks_title_zh": "链接文字(中文)",
|
||||
"navlinks_title_en": "链接文字(英文)",
|
||||
"navlinks_url": "链接地址",
|
||||
"navlinks_url_hint": "可以是相对路径(/about)或完整链接(https://example.com)",
|
||||
"navlinks_open_new": "在新窗口打开",
|
||||
"navlinks_enabled": "启用",
|
||||
"navlinks_sort": "排序",
|
||||
"navlinks_edit": "编辑",
|
||||
"navlinks_delete": "删除",
|
||||
"navlinks_confirm_delete": "确定要删除这个链接吗?",
|
||||
"navlinks_no_links": "还没有配置导航链接。",
|
||||
"navlinks_save": "保存",
|
||||
"navlinks_cancel": "取消",
|
||||
"settings_logo": "Logo",
|
||||
"settings_logo_url": "Logo 链接(外链地址)",
|
||||
"settings_logo_upload": "或上传 Logo 图片",
|
||||
"settings_logo_clear": "移除当前 Logo",
|
||||
"settings_favicon": "网站图标(Favicon)",
|
||||
"settings_favicon_url": "Favicon 链接(外链地址)",
|
||||
"settings_favicon_current": "当前 Favicon",
|
||||
"settings_favicon_hint": "推荐:.ico、.png 或 .svg 格式,32x32 或 16x16 像素",
|
||||
"settings_favicon_clear": "移除当前 Favicon",
|
||||
"settings_logo_text_zh": "站点标题(中文)",
|
||||
"settings_logo_text_en": "站点标题(英文)",
|
||||
"settings_header_text_zh": "顶部横幅文本(中文)",
|
||||
"settings_header_text_en": "顶部横幅文本(英文)",
|
||||
"settings_home_welcome_zh": "主页欢迎标题(中文)",
|
||||
"settings_home_welcome_en": "主页欢迎标题(英文)",
|
||||
"settings_home_subtitle_zh": "主页副标题(中文)",
|
||||
"settings_home_subtitle_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": "其他",
|
||||
|
||||
// 评论
|
||||
"comments_title": "评论",
|
||||
"comments_count": "%d 条评论",
|
||||
"comments_empty": "暂无评论,快来抢沙发吧。",
|
||||
"comments_reply": "回复",
|
||||
"comments_cancel_reply": "取消回复",
|
||||
"comments_replying_to": "回复 %s",
|
||||
"comments_name": "名称",
|
||||
"comments_email": "邮箱",
|
||||
"comments_email_hint": "用于显示 Gravatar 头像,不会公开。",
|
||||
"comments_website": "网址(可选)",
|
||||
"comments_content": "评论内容",
|
||||
"comments_content_ph": "支持 Markdown 语法...",
|
||||
"comments_private": "私密评论",
|
||||
"comments_private_hint": "仅你和管理员可见。",
|
||||
"comments_preview": "预览",
|
||||
"comments_edit": "编辑",
|
||||
"comments_emoji": "表情",
|
||||
"comments_markdown_help": "Markdown 帮助",
|
||||
"comments_submit": "发表评论",
|
||||
"comments_posted": "评论已发表。",
|
||||
"comments_pending_notice": "你的评论正在等待审核。",
|
||||
"comments_disabled": "评论功能已关闭。",
|
||||
"comments_guests_disabled": "请先登录后再评论。",
|
||||
"comments_required_name": "请填写名称。",
|
||||
"comments_required_email": "请填写邮箱。",
|
||||
"comments_required_content":"请填写评论内容。",
|
||||
"comments_invalid_email": "请输入有效的邮箱地址。",
|
||||
"comments_invalid_url": "请输入有效的网址。",
|
||||
"comments_too_long": "评论内容过长(最多 %d 字)。",
|
||||
"comments_pending": "待审核",
|
||||
"comments_approved": "已通过",
|
||||
"comments_rejected": "已拒绝",
|
||||
"comments_private_badge": "私密",
|
||||
"comments_markdown_bold": "**粗体**",
|
||||
"comments_markdown_italic": "*斜体*",
|
||||
"comments_markdown_code": "`代码`",
|
||||
"comments_markdown_link": "[文本](网址)",
|
||||
"comments_markdown_quote": "> 引用",
|
||||
|
||||
// 后台:评论
|
||||
"admin_comments": "评论管理",
|
||||
"comment_manage": "评论管理",
|
||||
"admin_comments_title": "评论管理",
|
||||
"comment_status_pending": "待审核",
|
||||
"comment_status_approved": "已通过",
|
||||
"comment_status_rejected": "已拒绝",
|
||||
"comment_status_all": "全部",
|
||||
"comment_approve": "通过",
|
||||
"comment_reject": "拒绝",
|
||||
"comment_delete": "删除",
|
||||
"comment_delete_confirm": "删除该评论?",
|
||||
"comment_col_author": "评论者",
|
||||
"comment_col_article": "文章",
|
||||
"comment_col_content": "内容",
|
||||
"comment_col_status": "状态",
|
||||
"comment_col_time": "时间",
|
||||
"comment_col_actions": "操作",
|
||||
"comment_empty": "暂无评论。",
|
||||
"comment_approved": "评论已通过。",
|
||||
"comment_rejected": "评论已拒绝。",
|
||||
"comment_deleted": "评论已删除。",
|
||||
|
||||
// 后台:评论设置
|
||||
"comment_settings_title": "评论设置",
|
||||
"comment_settings_desc": "控制是否启用评论以及评论的审核方式。",
|
||||
"comment_settings_enabled": "启用评论",
|
||||
"comment_settings_allow_guest": "允许匿名评论",
|
||||
"comment_settings_guest_approval": "非登录用户评论需审核",
|
||||
"comment_settings_use_gravatar": "使用 Gravatar 头像",
|
||||
"comment_settings_use_gravatar_hint": "关闭后,头像将显示作者名称首字母的彩色占位。",
|
||||
|
||||
// 后台:用户管理
|
||||
"admin_users": "用户",
|
||||
"admin_users_title": "用户",
|
||||
"user_list_title": "用户管理",
|
||||
"user_new": "新建用户",
|
||||
"user_create_title": "新建用户",
|
||||
"user_edit_title": "编辑用户",
|
||||
"user_col_username": "用户名",
|
||||
"user_col_display": "显示名称",
|
||||
"user_col_email": "邮箱",
|
||||
"user_col_role": "角色",
|
||||
"user_col_status": "状态",
|
||||
"user_col_created": "创建时间",
|
||||
"user_col_actions": "操作",
|
||||
"user_username": "用户名",
|
||||
"user_password": "密码",
|
||||
"user_password_hint": "留空则保持原密码不变。",
|
||||
"user_display_name": "显示名称",
|
||||
"user_email": "邮箱",
|
||||
"user_gender": "性别",
|
||||
"user_birthday": "生日",
|
||||
"user_role": "角色",
|
||||
"user_status": "状态",
|
||||
"role_admin": "管理员",
|
||||
"role_author": "作者",
|
||||
"status_normal": "正常",
|
||||
"status_disabled": "禁用",
|
||||
"status_locked": "锁定",
|
||||
"status_unactivated": "未激活",
|
||||
"user_created": "用户已创建。",
|
||||
"user_updated": "用户已更新。",
|
||||
"user_deleted": "用户已删除。",
|
||||
"user_delete_confirm": "确认删除该用户?",
|
||||
"user_empty": "暂无用户。",
|
||||
"user_username_required": "请填写用户名。",
|
||||
"user_password_required": "请填写密码。",
|
||||
"user_username_exists": "用户名已存在。",
|
||||
"user_cannot_delete_self": "不能删除自己的账号。",
|
||||
"user_cannot_disable_self": "不能禁用或锁定自己的账号。",
|
||||
"user_cannot_remove_last_admin": "不能移除最后一个管理员。",
|
||||
"dash_user_mgmt": "用户管理",
|
||||
|
||||
// 阅读统计
|
||||
"analytics_views_title": "阅读统计",
|
||||
"analytics_views_desc": "追踪文章阅读量,识别独立访客和爬虫。",
|
||||
"analytics_stats_total": "总阅读量",
|
||||
"analytics_stats_human_views": "真人阅读",
|
||||
"analytics_stats_bot_views": "爬虫访问",
|
||||
"analytics_stats_unique_ips": "独立IP",
|
||||
"analytics_stats_unique_users": "独立用户",
|
||||
"analytics_article_stats": "文章统计(前20篇)",
|
||||
"analytics_col_article": "文章",
|
||||
"analytics_col_total_views": "总阅读量",
|
||||
"analytics_col_human_views": "真人",
|
||||
"analytics_col_bot_views": "爬虫",
|
||||
"analytics_col_unique_ips": "独立IP",
|
||||
"analytics_col_time": "时间",
|
||||
"analytics_col_user": "用户",
|
||||
"analytics_col_ip": "IP地址",
|
||||
"analytics_col_user_agent": "User Agent",
|
||||
"analytics_col_is_bot": "类型",
|
||||
"analytics_filters": "筛选条件",
|
||||
"analytics_filter_article": "文章标题",
|
||||
"analytics_filter_article_placeholder": "搜索文章标题",
|
||||
"analytics_filter_ip": "IP地址",
|
||||
"analytics_filter_ip_placeholder": "按IP搜索",
|
||||
"analytics_filter_show_bots": "显示爬虫",
|
||||
"analytics_include_bots": "包含爬虫流量",
|
||||
"analytics_apply_filters": "应用筛选",
|
||||
"analytics_recent_views": "最近访问记录",
|
||||
"analytics_bot": "爬虫",
|
||||
"analytics_human": "真人",
|
||||
"analytics_anonymous": "匿名用户",
|
||||
"analytics_article_deleted": "(已删除)",
|
||||
"analytics_no_data": "暂无数据。",
|
||||
"analytics_no_views": "未找到访问记录。",
|
||||
"analytics_showing": "显示",
|
||||
"analytics_of": "共",
|
||||
"analytics_load_more": "加载更多",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="blog_go"
|
||||
SERVICE_USER="blog_go"
|
||||
CONFIG_DIR="/etc/${SERVICE_NAME}"
|
||||
DATA_DIR="/srv/${SERVICE_NAME}"
|
||||
INSTALL_DIR="/opt/${SERVICE_NAME}"
|
||||
BINARY_NAME="${SERVICE_NAME}"
|
||||
SOCKET_DIR="/run/${SERVICE_NAME}"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "请使用 root 权限运行: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
echo "拉取最新代码..."
|
||||
git pull
|
||||
|
||||
echo "编译 Go 程序..."
|
||||
go build -o "${BINARY_NAME}" .
|
||||
|
||||
echo "检查系统用户..."
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin "${SERVICE_USER}"
|
||||
fi
|
||||
|
||||
echo "创建目录..."
|
||||
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
install -d -m 0755 "${SOCKET_DIR}"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${SOCKET_DIR}"
|
||||
|
||||
echo "安装程序和模板文件..."
|
||||
install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
rm -rf "${INSTALL_DIR}/templates"
|
||||
cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates"
|
||||
chown -R root:root "${INSTALL_DIR}/templates"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
chmod 0755 "${INSTALL_DIR}"
|
||||
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
|
||||
find "${INSTALL_DIR}/templates" -type f -exec chmod 0644 {} \;
|
||||
|
||||
SOCKET_PATH="${SOCKET_DIR}/web.sock"
|
||||
|
||||
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
cat > "${CONFIG_DIR}/config.yaml" <<EOF
|
||||
database:
|
||||
type: sqlite
|
||||
dsn: ""
|
||||
web:
|
||||
port: "8080"
|
||||
socket: ${SOCKET_PATH}
|
||||
path: ${DATA_DIR}
|
||||
secret: ${SECRET}
|
||||
EOF
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/config.yaml"
|
||||
chmod 0640 "${CONFIG_DIR}/config.yaml"
|
||||
fi
|
||||
|
||||
echo "写入 systemd 服务文件..."
|
||||
cat > "${SERVICE_FILE}" <<EOF
|
||||
[Unit]
|
||||
Description=Blog Go Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -config ${CONFIG_DIR}/config.yaml
|
||||
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chmod 666 ${SOCKET_DIR}/web.sock'
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=${CONFIG_DIR} ${DATA_DIR} ${INSTALL_DIR} ${SOCKET_DIR}
|
||||
RuntimeDirectory=${SERVICE_NAME}
|
||||
RuntimeDirectoryMode=0755
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
echo "部署完成,服务状态:"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
@@ -15,12 +18,19 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 0. Parse command-line flags.
|
||||
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
|
||||
flag.Parse()
|
||||
|
||||
// 1. Load configuration (auto-creates if missing).
|
||||
cfg := config.LoadConfig()
|
||||
cfg := config.LoadConfig(*configFlag)
|
||||
|
||||
// 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{
|
||||
@@ -46,18 +56,85 @@ func main() {
|
||||
router.Use(middleware.SetUserContext(db))
|
||||
|
||||
// 8. Register routes.
|
||||
router.GET("/", handlers.HomePage())
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db))
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.POST("/register", handlers.Register(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||
|
||||
// Protected admin routes.
|
||||
// Protected admin routes (admin role only).
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard())
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||
admin.POST("/articles/new", handlers.ArticleCreate(db))
|
||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
|
||||
}
|
||||
|
||||
// Protected admin comment management routes (admin role only).
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
comments.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
comments.POST("/:id/reject", handlers.CommentReject(db))
|
||||
comments.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// Protected admin user-management routes (admin role only).
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (admin role only).
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
attachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// Protected admin settings routes (platform configuration).
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
|
||||
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||
settings.POST("/comments", handlers.CommentSettingsSave(db))
|
||||
}
|
||||
|
||||
// Protected admin analytics routes (reading statistics).
|
||||
analytics := router.Group("/admin/analytics")
|
||||
analytics.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
||||
}
|
||||
|
||||
// Protected profile routes.
|
||||
@@ -69,10 +146,61 @@ func main() {
|
||||
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
}
|
||||
|
||||
// 9. Start the server.
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
log.Printf("Go Blog starting on http://localhost%s", addr)
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
// Protected user article management routes (for non-admin users).
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired())
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes for user articles.
|
||||
myAttachments := router.Group("/my/articles")
|
||||
myAttachments.Use(middleware.AuthRequired())
|
||||
{
|
||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 9. Start the server.
|
||||
webPort := cfg.Web.Port
|
||||
socketPath := cfg.Web.Socket
|
||||
usePort := webPort != "" && webPort != "0"
|
||||
useSocket := socketPath != ""
|
||||
|
||||
if !usePort && !useSocket {
|
||||
log.Fatalf("Neither port nor socket is configured — at least one must be enabled")
|
||||
}
|
||||
|
||||
if usePort {
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%s", webPort)
|
||||
log.Printf("Go Blog starting on http://localhost%s", addr)
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatalf("Failed to start HTTP server: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if useSocket {
|
||||
go func() {
|
||||
os.Remove(socketPath) // remove stale socket file if exists
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err)
|
||||
}
|
||||
log.Printf("Go Blog starting on unix socket %s", socketPath)
|
||||
if err := router.RunListener(listener); err != nil {
|
||||
log.Fatalf("Failed to serve on unix socket: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Block forever.
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,28 @@ func AuthRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired is middleware that restricts a route to admin-role users. It
|
||||
// must run after AuthRequired (which guarantees a session user exists). Non-admin
|
||||
// users are redirected back to the admin dashboard.
|
||||
func AdminRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil || user.Role != models.RoleAdmin {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SetUserContext is global middleware that reads the session and sets
|
||||
// template-friendly context values for all pages (language, auth state, etc.).
|
||||
func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
@@ -95,6 +117,24 @@ 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_favicon", site.Favicon)
|
||||
c.Set("site_favicon_is_url", site.FaviconIsURL())
|
||||
c.Set("site_logo_text", site.LogoText(string(lang)))
|
||||
c.Set("site_header_text", site.HeaderText(string(lang)))
|
||||
c.Set("site_home_welcome", site.HomeWelcome(string(lang)))
|
||||
c.Set("site_home_subtitle", site.HomeSubtitle(string(lang)))
|
||||
c.Set("site_footer_text", site.FooterText(string(lang)))
|
||||
|
||||
// --- Navigation links (from DB cache) ---
|
||||
navLinks := models.GetNavLinks()
|
||||
c.Set("nav_links", navLinks)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type Article struct {
|
||||
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArticleTag represents the many-to-many relationship between articles and tags.
|
||||
type ArticleTag struct {
|
||||
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
|
||||
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (ArticleTag) TableName() string {
|
||||
return "article_tags"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ArticleView represents a unique article view record.
|
||||
// Each record tracks one unique visit (by IP or user) to an article.
|
||||
type ArticleView struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ArticleID uint `gorm:"not null;index:idx_article_views_article" json:"article_id"`
|
||||
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // NULL for anonymous users
|
||||
IP string `gorm:"size:64;not null;index:idx_article_views_ip" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
IsBot bool `gorm:"default:false;index:idx_article_views_bot" json:"is_bot"`
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (ArticleView) TableName() string {
|
||||
return "article_views"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to ensure we don't create duplicate records.
|
||||
// This is a safety check in addition to application-level deduplication.
|
||||
func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
|
||||
var count int64
|
||||
query := tx.Model(&ArticleView{}).
|
||||
Where("article_id = ? AND ip = ?", av.ArticleID, av.IP)
|
||||
|
||||
if av.UserID != nil {
|
||||
query = query.Where("user_id = ?", *av.UserID)
|
||||
} else {
|
||||
query = query.Where("user_id IS NULL")
|
||||
}
|
||||
|
||||
query.Count(&count)
|
||||
if count > 0 {
|
||||
// Record already exists, skip creation
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to an article.
|
||||
//
|
||||
// Lifecycle (plan A — upload-then-bind):
|
||||
// - On the article-create page the article does not exist yet, so ArticleID
|
||||
// is 0 and the row is temporarily owned by SessionToken (a random value
|
||||
// generated for the page session).
|
||||
// - When the article is saved, ArticleCreate binds pending rows by
|
||||
// SessionToken, setting their ArticleID and clearing the token.
|
||||
// - On the edit page uploads carry the real ArticleID directly.
|
||||
//
|
||||
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
|
||||
// writing, the handler checks whether a file with that name already exists on
|
||||
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
|
||||
// the disk file is removed only when no Attachment rows reference it.
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
|
||||
UploaderID uint `gorm:"index" json:"uploader_id"`
|
||||
Filename string `gorm:"size:255" json:"filename"` // original filename
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
|
||||
Ext string `gorm:"size:32" json:"ext"`
|
||||
MIME string `gorm:"size:128" json:"mime"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// AttachmentCategoryImage reports whether this attachment is an image (used to
|
||||
// decide markdown insertion form: ![]() vs []()).
|
||||
func (a *Attachment) IsImage() bool {
|
||||
return a.Category == CategoryImage
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// botPatterns contains common bot/crawler/spider User-Agent patterns.
|
||||
var botPatterns = []string{
|
||||
"bot", "crawler", "spider", "scraper", "scraping",
|
||||
"googlebot", "bingbot", "baiduspider", "yandexbot",
|
||||
"duckduckbot", "slurp", "teoma", "ia_archiver",
|
||||
"facebookexternalhit", "facebot", "twitterbot",
|
||||
"whatsapp", "telegram", "slackbot", "discordbot",
|
||||
"linkedinbot", "pinterestbot", "tumblr",
|
||||
"semrushbot", "ahrefsbot", "mj12bot", "dotbot",
|
||||
"archive.org_bot", "serpstatbot", "dataforseo",
|
||||
"petalbot", "gptbot", "claudebot", "anthropic-ai",
|
||||
"bytespider", "applebot", "seznambot",
|
||||
"headless", "phantom", "selenium", "puppeteer",
|
||||
}
|
||||
|
||||
// IsBot checks if the given User-Agent string matches known bot patterns.
|
||||
// It performs a case-insensitive substring match against common bot identifiers.
|
||||
func IsBot(userAgent string) bool {
|
||||
if userAgent == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ua := strings.ToLower(userAgent)
|
||||
|
||||
for _, pattern := range botPatterns {
|
||||
if strings.Contains(ua, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Comment status constants.
|
||||
const (
|
||||
CommentPending = 0 // 待审核
|
||||
CommentApproved = 1 // 已通过
|
||||
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
|
||||
)
|
||||
|
||||
// Comment represents one reader-submitted comment on an article. Comments may
|
||||
// be nested via ParentID and authored by either a logged-in user (UserID) or
|
||||
// an anonymous visitor identified by a long-lived GuestToken cookie.
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
|
||||
// Authorship: logged-in users get UserID; anonymous visitors get a random
|
||||
// GuestToken stored in a cookie so they can see their own pending/private
|
||||
// comments on subsequent page loads.
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
GuestToken string `gorm:"size:64;index" json:"-"`
|
||||
|
||||
AuthorName string `gorm:"not null;size:64" json:"author_name"`
|
||||
Email string `gorm:"not null;size:255" json:"-"`
|
||||
EmailHash string `gorm:"size:64;index" json:"email_hash"`
|
||||
Website string `gorm:"size:255" json:"website,omitempty"`
|
||||
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
|
||||
IsPrivate bool `gorm:"default:false;index" json:"is_private"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
|
||||
IPAddress string `gorm:"size:64" json:"-"`
|
||||
UserAgent string `gorm:"size:512" json:"-"`
|
||||
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
|
||||
// is the form expected by Gravatar.
|
||||
func HashEmail(email string) string {
|
||||
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
|
||||
// Falls back to the "identicon" default avatar when no Gravatar exists.
|
||||
func (c *Comment) GravatarURL(size int) string {
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
}
|
||||
hash := c.EmailHash
|
||||
if hash == "" {
|
||||
hash = HashEmail(c.Email)
|
||||
}
|
||||
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
|
||||
}
|
||||
|
||||
// MaskedEmail returns the email with the local part partially obscured, for
|
||||
// admin-side listings that should hint at identity without exposing the
|
||||
// full address.
|
||||
func (c *Comment) MaskedEmail() string {
|
||||
email := c.Email
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 {
|
||||
return email
|
||||
}
|
||||
local := email[:at]
|
||||
host := email[at:]
|
||||
if len(local) <= 2 {
|
||||
return string(local[0]) + "***" + host
|
||||
}
|
||||
return string(local[0]) + "***" + string(local[len(local)-1]) + host
|
||||
}
|
||||
|
||||
// AuthorInitial returns an uppercase first character of AuthorName for use as
|
||||
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
|
||||
// the name is empty.
|
||||
func (c *Comment) AuthorInitial() string {
|
||||
name := strings.TrimSpace(c.AuthorName)
|
||||
if name == "" {
|
||||
return "?"
|
||||
}
|
||||
r := []rune(name)
|
||||
return strings.ToUpper(string(r[0]))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CommentConfig holds the singleton (id=1) global comment policy.
|
||||
type CommentConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
|
||||
UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (CommentConfig) TableName() string {
|
||||
return "comment_configs"
|
||||
}
|
||||
|
||||
// defaultCommentConfig returns the in-memory fallback used before the DB row is
|
||||
// seeded, matching the seed defaults.
|
||||
func defaultCommentConfig() *CommentConfig {
|
||||
return &CommentConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
comment *CommentConfig
|
||||
types []UploadFileType
|
||||
baseURLs []DownloadBaseURL
|
||||
navLinks []NavLink
|
||||
}{
|
||||
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 cc CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err == nil {
|
||||
configCache.comment = &cc
|
||||
} else {
|
||||
configCache.comment = defaultCommentConfig()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var n []NavLink
|
||||
if err := db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&n).Error; err == nil {
|
||||
configCache.navLinks = n
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetCommentConfig returns the cached comment policy.
|
||||
func GetCommentConfig() *CommentConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.comment
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// GetNavLinks returns the cached list of enabled navigation links, sorted by sort order.
|
||||
func GetNavLinks() []NavLink {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.navLinks
|
||||
}
|
||||
+7
-1
@@ -45,10 +45,16 @@ 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{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
seedCommentConfig(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// NavLink represents a custom navigation link in the header.
|
||||
type NavLink struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // Link text (Chinese)
|
||||
TitleEn string `gorm:"size:100;not null" json:"title_en"` // Link text (English)
|
||||
URL string `gorm:"size:512;not null" json:"url"` // Target URL
|
||||
OpenNew bool `gorm:"default:false" json:"open_new"` // Open in new window
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // Show/hide link
|
||||
Sort int `gorm:"default:0;index" json:"sort"` // Display order (lower = first)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (NavLink) TableName() string {
|
||||
return "nav_links"
|
||||
}
|
||||
|
||||
// Title returns the link text for the given language code, falling back to
|
||||
// the other language when the requested one is empty.
|
||||
func (n *NavLink) Title(lang string) string {
|
||||
if lang == "zh" {
|
||||
if n.TitleZh != "" {
|
||||
return n.TitleZh
|
||||
}
|
||||
return n.TitleEn
|
||||
}
|
||||
if n.TitleEn != "" {
|
||||
return n.TitleEn
|
||||
}
|
||||
return n.TitleZh
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
|
||||
func seedCommentConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&CommentConfig{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
c := &CommentConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed comment_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,123 @@
|
||||
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
|
||||
Favicon string `gorm:"size:512" json:"favicon"` // 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)
|
||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
|
||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
|
||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
|
||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (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)
|
||||
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
|
||||
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")
|
||||
}
|
||||
|
||||
// FaviconIsURL reports whether the favicon value is an external URL rather than a
|
||||
// local filename.
|
||||
func (s *SiteSetting) FaviconIsURL() bool {
|
||||
if s == nil || s.Favicon == "" {
|
||||
return false
|
||||
}
|
||||
return len(s.Favicon) >= 4 && (s.Favicon[: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
|
||||
}
|
||||
|
||||
// HomeWelcome returns the home page welcome heading for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) HomeWelcome(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeWelcomeZh != "" {
|
||||
return s.HomeWelcomeZh
|
||||
}
|
||||
return s.HomeWelcomeEn
|
||||
}
|
||||
if s.HomeWelcomeEn != "" {
|
||||
return s.HomeWelcomeEn
|
||||
}
|
||||
return s.HomeWelcomeZh
|
||||
}
|
||||
|
||||
// HomeSubtitle returns the home page subtitle for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
func (s *SiteSetting) HomeSubtitle(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeSubtitleZh != "" {
|
||||
return s.HomeSubtitleZh
|
||||
}
|
||||
return s.HomeSubtitleEn
|
||||
}
|
||||
if s.HomeSubtitleEn != "" {
|
||||
return s.HomeSubtitleEn
|
||||
}
|
||||
return s.HomeSubtitleZh
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tag represents a blog post tag with multi-language support.
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
NameZh string `gorm:"size:50;not null" json:"name_zh"`
|
||||
NameEn string `gorm:"size:50;not null" json:"name_en"`
|
||||
Slug string `gorm:"size:100;uniqueIndex;not null" json:"slug"`
|
||||
Count int `gorm:"default:0" json:"count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Tag) TableName() string {
|
||||
return "tags"
|
||||
}
|
||||
|
||||
// Name returns the tag name for the specified language.
|
||||
func (t *Tag) Name(lang string) string {
|
||||
if lang == "zh" {
|
||||
return t.NameZh
|
||||
}
|
||||
return t.NameEn
|
||||
}
|
||||
|
||||
// generateTagSlug creates a URL-friendly slug from tag name.
|
||||
func generateTagSlug(name string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(name))
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
return slug
|
||||
}
|
||||
|
||||
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
|
||||
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
|
||||
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
nameZh = strings.TrimSpace(nameZh)
|
||||
nameEn = strings.TrimSpace(nameEn)
|
||||
|
||||
// If only one name is provided, use it for both languages
|
||||
if nameZh == "" && nameEn != "" {
|
||||
nameZh = nameEn
|
||||
} else if nameEn == "" && nameZh != "" {
|
||||
nameEn = nameZh
|
||||
}
|
||||
|
||||
if nameZh == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slug := generateTagSlug(nameZh)
|
||||
|
||||
var tag Tag
|
||||
err := db.Where("slug = ?", slug).First(&tag).Error
|
||||
if err == nil {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
tag = Tag{
|
||||
NameZh: nameZh,
|
||||
NameEn: nameEn,
|
||||
Slug: slug,
|
||||
Count: 0,
|
||||
}
|
||||
|
||||
if err := db.Create(&tag).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// GetAllTags returns all tags ordered by count descending.
|
||||
func GetAllTags(db *gorm.DB) ([]Tag, error) {
|
||||
var tags []Tag
|
||||
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
// GetTagBySlug returns a tag by its slug.
|
||||
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
|
||||
var tag Tag
|
||||
err := db.Where("slug = ?", slug).First(&tag).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// UpdateTagCount recalculates the article count for a tag.
|
||||
func UpdateTagCount(db *gorm.DB, tagID uint) error {
|
||||
var count int64
|
||||
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
|
||||
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
|
||||
}
|
||||
|
||||
// UpdateAllTagCounts recalculates article counts for all tags.
|
||||
func UpdateAllTagCounts(db *gorm.DB) error {
|
||||
// Get all tags
|
||||
var tags []Tag
|
||||
if err := db.Find(&tags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update count for each tag
|
||||
for _, tag := range tags {
|
||||
if err := UpdateTagCount(db, tag.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Add favicon field to site_settings table
|
||||
-- Date: 2026-06-22
|
||||
-- Description: Add support for custom favicon in site settings
|
||||
|
||||
-- Add favicon column if it doesn't exist
|
||||
ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS favicon VARCHAR(512) DEFAULT '';
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN site_settings.favicon IS 'Local filename (served under /uploads/logos) OR a full URL for the site favicon';
|
||||
@@ -0,0 +1,170 @@
|
||||
{{define "analytics_views"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-7xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "analytics_views_title"}}</h2>
|
||||
<p class="text-gray-500 mt-1">{{index .Tr "analytics_views_desc"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "analytics_stats_total"}}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{{.Stats.TotalViews}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "analytics_stats_human_views"}}</p>
|
||||
<p class="text-2xl font-bold text-green-600 mt-1">{{.Stats.HumanViews}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "analytics_stats_bot_views"}}</p>
|
||||
<p class="text-2xl font-bold text-orange-600 mt-1">{{.Stats.BotViews}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "analytics_stats_unique_ips"}}</p>
|
||||
<p class="text-2xl font-bold text-blue-600 mt-1">{{.Stats.UniqueIPs}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "analytics_stats_unique_users"}}</p>
|
||||
<p class="text-2xl font-bold text-purple-600 mt-1">{{.Stats.UniqueUsers}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Article Statistics -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-4">{{index .Tr "analytics_article_stats"}}</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_article"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600 text-right">{{index .Tr "analytics_col_total_views"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600 text-right">{{index .Tr "analytics_col_human_views"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600 text-right">{{index .Tr "analytics_col_bot_views"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600 text-right">{{index .Tr "analytics_col_unique_ips"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .ArticleStats}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-gray-800">{{.Title}}</td>
|
||||
<td class="px-4 py-3 text-right font-medium">{{.ViewCount}}</td>
|
||||
<td class="px-4 py-3 text-right text-green-600">{{.HumanViews}}</td>
|
||||
<td class="px-4 py-3 text-right text-orange-600">{{.BotViews}}</td>
|
||||
<td class="px-4 py-3 text-right text-blue-600">{{.UniqueIPs}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500">{{index $.Tr "analytics_no_data"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "analytics_filters"}}</h3>
|
||||
<form method="get" action="/admin/analytics/views" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "analytics_filter_article"}}</label>
|
||||
<input type="text" name="article_title" value="{{.Filters.ArticleTitle}}" placeholder="{{index .Tr "analytics_filter_article_placeholder"}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "analytics_filter_ip"}}</label>
|
||||
<input type="text" name="ip" value="{{.Filters.IP}}" placeholder="{{index .Tr "analytics_filter_ip_placeholder"}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "analytics_filter_show_bots"}}</label>
|
||||
<label class="flex items-center mt-2">
|
||||
<input type="checkbox" name="show_bots" value="1" {{if .Filters.ShowBots}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">{{index .Tr "analytics_include_bots"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "analytics_apply_filters"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- View Records -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "analytics_recent_views"}}</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_time"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_article"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_user"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_ip"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600">{{index .Tr "analytics_col_user_agent"}}</th>
|
||||
<th class="px-4 py-3 font-semibold text-gray-600 text-center">{{index .Tr "analytics_col_is_bot"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .Views}}
|
||||
<tr class="hover:bg-gray-50 {{if .IsBot}}bg-orange-50{{end}}">
|
||||
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="px-4 py-3 text-gray-800">
|
||||
{{if .Article.Title}}
|
||||
<a href="/article/{{.Article.Slug}}" target="_blank" class="text-blue-600 hover:text-blue-800">{{.Article.Title}}</a>
|
||||
{{else}}
|
||||
<span class="text-gray-400">{{index $.Tr "analytics_article_deleted"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-700">
|
||||
{{if .User}}
|
||||
{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}
|
||||
{{else}}
|
||||
<span class="text-gray-400">{{index $.Tr "analytics_anonymous"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{{.IP}}</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs max-w-xs truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{{if .IsBot}}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-orange-100 text-orange-700">
|
||||
🤖 {{index $.Tr "analytics_bot"}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
👤 {{index $.Tr "analytics_human"}}
|
||||
</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500">{{index .Tr "analytics_no_views"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Pagination.HasMore}}
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
{{index .Tr "analytics_showing"}} {{len .Views}} {{index .Tr "analytics_of"}} {{.Pagination.Total}}
|
||||
</p>
|
||||
<a href="?page={{.Pagination.NextPage}}{{if .Filters.ArticleTitle}}&article_title={{.Filters.ArticleTitle}}{{end}}{{if .Filters.IP}}&ip={{.Filters.IP}}{{end}}{{if .Filters.ShowBots}}&show_bots=1{{end}}"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors text-sm">
|
||||
{{index .Tr "analytics_load_more"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -2,7 +2,7 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<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}}
|
||||
<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,10 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/articles/new" method="post" class="space-y-6">
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||
@@ -50,9 +53,50 @@
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||||
<input type="text" name="tags" value="{{.FormTags}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||||
<button type="button" id="attachmentUploadBtn"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled>
|
||||
{{index .Tr "article_upload"}}
|
||||
</button>
|
||||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Published At -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- IsTop -->
|
||||
<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">
|
||||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||
</div>
|
||||
@@ -89,6 +133,115 @@ var easyMDE = new EasyMDE({
|
||||
status: false,
|
||||
minHeight: '300px'
|
||||
});
|
||||
|
||||
// ---- Attachments ----
|
||||
(function () {
|
||||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
var sessionToken = "{{ .SessionToken }}";
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
var cm = easyMDE.codemirror;
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = "{{index .Tr "settings_delete"}}";
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
|
||||
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
if (articleID) { fd.append('article_id', articleID); }
|
||||
else { fd.append('session_token', sessionToken); }
|
||||
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
|
||||
fetch('/admin/articles/attachments', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (articleID) {
|
||||
fetch('/admin/articles/' + articleID + '/attachments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,94 @@
|
||||
{{define "comment_list"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "admin_comments_title"}}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status tabs -->
|
||||
<div class="flex gap-2 mb-6 text-sm">
|
||||
<a href="/admin/comments?status=pending"
|
||||
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "pending"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||
{{index .Tr "comment_status_pending"}}
|
||||
{{if gt .PendingCount 0}}<span class="ml-1 inline-flex items-center justify-center bg-red-500 text-white text-xs rounded-full px-2">{{.PendingCount}}</span>{{end}}
|
||||
</a>
|
||||
<a href="/admin/comments?status=approved"
|
||||
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "approved"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||
{{index .Tr "comment_status_approved"}}
|
||||
</a>
|
||||
<a href="/admin/comments?status=rejected"
|
||||
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "rejected"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||
{{index .Tr "comment_status_rejected"}}
|
||||
</a>
|
||||
<a href="/admin/comments?status=all"
|
||||
class="px-4 py-2 rounded-lg font-medium {{if eq .Status "all"}}bg-blue-600 text-white{{else}}bg-gray-200 text-gray-700 hover:bg-gray-300{{end}} transition-colors">
|
||||
{{index .Tr "comment_status_all"}}
|
||||
</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="space-y-4">
|
||||
{{range .Comments}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<img src="{{.GravatarURL}}" alt="" class="w-10 h-10 rounded-full bg-gray-100">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-semibold text-gray-800">{{.AuthorName}}</span>
|
||||
{{if .Website}}<a href="{{.Website}}" target="_blank" rel="nofollow ugc noopener" class="text-blue-500 hover:underline text-xs">{{.Website}}</a>{{end}}
|
||||
<span class="text-gray-400 text-xs">{{.MaskedEmail}}</span>
|
||||
<span class="text-gray-300">·</span>
|
||||
<span class="text-gray-400 text-xs">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
{{if .IPAddress}}<span class="text-gray-400 text-xs">IP: {{.IPAddress}}</span>{{end}}
|
||||
{{if .IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index $.Tr "comments_private_badge"}}</span>{{end}}
|
||||
<span class="text-xs {{.StatusBadge}} px-2 py-0.5 rounded">{{.StatusLabel}}</span>
|
||||
</div>
|
||||
{{if .ArticleTitle}}
|
||||
<a href="/article/{{.ArticleSlug}}#comment-{{.ID}}" target="_blank" class="text-xs text-gray-500 hover:text-blue-600 mt-1 inline-block">
|
||||
↗ {{.ArticleTitle}}
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Content}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-3 text-sm">
|
||||
{{if eq .Status 0}}
|
||||
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
|
||||
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if ne .Status 2}}
|
||||
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
|
||||
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center text-gray-500">
|
||||
{{index .Tr "comment_empty"}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||
var raw = el.getAttribute('data-md') || '';
|
||||
var html = marked.parse(raw);
|
||||
el.innerHTML = DOMPurify.sanitize(html);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -20,7 +20,9 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_posts"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">{{.PostCount}}
|
||||
<span class="text-sm font-normal text-gray-400">({{.PublishedCount}} {{index .Tr "dash_published"}})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_pages"}}</p>
|
||||
@@ -28,14 +30,45 @@
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_users"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">1</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">{{.UserCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for future features -->
|
||||
<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>
|
||||
<p class="text-gray-500">{{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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{index .Tr "dash_new_article"}}
|
||||
</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>
|
||||
{{if eq .Role "admin"}}
|
||||
<a href="/admin/users"
|
||||
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 "dash_user_mgmt"}}
|
||||
</a>
|
||||
<a href="/admin/comments"
|
||||
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 "comment_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>
|
||||
<a href="/admin/analytics/views"
|
||||
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 "analytics_views_title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{{define "settings_comment"}}
|
||||
{{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 "comment_settings_title"}}</h2>
|
||||
<p class="text-gray-500 mb-4">{{index .Tr "comment_settings_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-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||
<a href="/admin/settings/comments" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "comment_settings_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/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
{{index .Tr "comment_settings_enabled"}}
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input type="checkbox" name="allow_guest" value="1" {{if .CommentConfig.AllowGuest}}checked{{end}}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
{{index .Tr "comment_settings_allow_guest"}}
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input type="checkbox" name="guest_require_approval" value="1" {{if .CommentConfig.GuestRequireApproval}}checked{{end}}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
{{index .Tr "comment_settings_guest_approval"}}
|
||||
</label>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input type="checkbox" name="use_gravatar" value="1" {{if .CommentConfig.UseGravatar}}checked{{end}}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
{{index .Tr "comment_settings_use_gravatar"}}
|
||||
</label>
|
||||
<p class="ml-7 text-xs text-gray-400">{{index .Tr "comment_settings_use_gravatar_hint"}}</p>
|
||||
</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>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{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/navlinks" 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_navlinks_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,200 @@
|
||||
{{define "settings_navlinks"}}
|
||||
{{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_navlinks_title"}}</h2>
|
||||
<p class="text-gray-500 mb-4">{{index .Tr "settings_navlinks_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/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_navlinks_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}}
|
||||
|
||||
<!-- Add New Link Form -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_zh"}}</label>
|
||||
<input type="text" name="title_zh" placeholder="首页" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_en"}}</label>
|
||||
<input type="text" name="title_en" placeholder="Home" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_url"}}</label>
|
||||
<input type="text" name="url" placeholder="/about" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "navlinks_url_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_sort"}}</label>
|
||||
<input type="number" name="sort" value="0" min="0"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div class="flex items-end gap-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="open_new" value="1" class="rounded border-gray-300">
|
||||
<span class="text-sm text-gray-700">{{index .Tr "navlinks_open_new"}}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="enabled" value="1" checked class="rounded border-gray-300">
|
||||
<span class="text-sm text-gray-700">{{index .Tr "navlinks_enabled"}}</span>
|
||||
</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 "navlinks_add"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Existing Links List -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_enabled"}}</h3>
|
||||
|
||||
{{if .NavLinks}}
|
||||
<div class="space-y-3">
|
||||
{{range .NavLinks}}
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors" id="link-{{.ID}}">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-medium text-gray-900">{{if .TitleZh}}{{.TitleZh}}{{else}}{{.TitleEn}}{{end}}</span>
|
||||
{{if not .Enabled}}
|
||||
<span class="px-2 py-0.5 bg-gray-200 text-gray-600 text-xs rounded">{{index $.Tr "status_disabled"}}</span>
|
||||
{{end}}
|
||||
{{if .OpenNew}}
|
||||
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">↗ {{index $.Tr "navlinks_open_new"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div><strong>ZH:</strong> {{.TitleZh}} | <strong>EN:</strong> {{.TitleEn}}</div>
|
||||
<div><strong>URL:</strong> <a href="{{.URL}}" target="_blank" class="text-blue-600 hover:underline">{{.URL}}</a></div>
|
||||
<div><strong>{{index $.Tr "navlinks_sort"}}:</strong> {{.Sort}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Toggle Button -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
|
||||
{{if .Enabled}}✓{{else}}✗{{end}} {{index $.Tr "settings_toggle"}}
|
||||
</button>
|
||||
</form>
|
||||
<!-- Edit Button -->
|
||||
<button onclick="editLink({{.ID}}, '{{.TitleZh}}', '{{.TitleEn}}', '{{.URL}}', {{if .OpenNew}}true{{else}}false{{end}}, {{.Sort}})" class="text-sm px-3 py-1.5 bg-blue-50 border border-blue-300 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors">
|
||||
{{index $.Tr "navlinks_edit"}}
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
|
||||
{{index $.Tr "navlinks_delete"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-center py-8">{{index .Tr "navlinks_no_links"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="editModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
|
||||
<form action="/admin/settings/navlinks" method="post" id="editForm">
|
||||
<input type="hidden" name="action" value="edit">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_zh"}}</label>
|
||||
<input type="text" name="title_zh" id="edit_title_zh" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_en"}}</label>
|
||||
<input type="text" name="title_en" id="edit_title_en" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_url"}}</label>
|
||||
<input type="text" name="url" id="edit_url" required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_sort"}}</label>
|
||||
<input type="number" name="sort" id="edit_sort" min="0"
|
||||
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 cursor-pointer">
|
||||
<input type="checkbox" name="open_new" id="edit_open_new" value="1" class="rounded border-gray-300">
|
||||
<span class="text-sm text-gray-700">{{index .Tr "navlinks_open_new"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="closeEditModal()" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "navlinks_cancel"}}
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "navlinks_save"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function editLink(id, titleZh, titleEn, url, openNew, sort) {
|
||||
document.getElementById('edit_id').value = id;
|
||||
document.getElementById('edit_title_zh').value = titleZh;
|
||||
document.getElementById('edit_title_en').value = titleEn;
|
||||
document.getElementById('edit_url').value = url;
|
||||
document.getElementById('edit_open_new').checked = openNew;
|
||||
document.getElementById('edit_sort').value = sort;
|
||||
document.getElementById('editModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
document.getElementById('editModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('editModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,150 @@
|
||||
{{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/navlinks" 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_navlinks_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 .SiteLogoIsURL}}
|
||||
<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 .SiteLogoIsURL}}{{.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>
|
||||
|
||||
<!-- Favicon -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_favicon"}}</label>
|
||||
{{if .Site.Favicon}}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-600">{{index .Tr "settings_favicon_current"}}:</span>
|
||||
{{if .SiteFaviconIsURL}}
|
||||
<img src="{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
|
||||
{{else}}
|
||||
<img src="/uploads/logos/{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
|
||||
<input type="file" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
|
||||
class="block w-full text-sm text-gray-500 mb-2">
|
||||
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<input type="checkbox" name="favicon_clear" value="1"> {{index .Tr "settings_favicon_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>
|
||||
|
||||
<!-- Home welcome & subtitle -->
|
||||
<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_home_welcome_zh"}}</label>
|
||||
<input type="text" name="home_welcome_zh" value="{{.Site.HomeWelcomeZh}}"
|
||||
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_home_welcome_en"}}</label>
|
||||
<input type="text" name="home_welcome_en" value="{{.Site.HomeWelcomeEn}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
<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_home_subtitle_zh"}}</label>
|
||||
<textarea name="home_subtitle_zh" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HomeSubtitleZh}}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_home_subtitle_en"}}</label>
|
||||
<textarea name="home_subtitle_en" rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HomeSubtitleEn}}</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>
|
||||
|
||||
<!-- Registration settings -->
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="allow_registration" value="1" {{if .Site.AllowRegistration}}checked{{end}}
|
||||
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="block text-sm font-semibold text-gray-700">{{index .Tr "settings_allow_registration"}}</span>
|
||||
<span class="block text-xs text-gray-500">{{index .Tr "settings_allow_registration_hint"}}</span>
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,140 @@
|
||||
{{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/navlinks" 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_navlinks_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}}
|
||||
@@ -0,0 +1,105 @@
|
||||
{{define "user_form"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Username (read-only on edit) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
|
||||
{{if .FormIsEdit}}
|
||||
<input type="text" value="{{.FormUsername}}" readonly
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-100 text-gray-500 cursor-not-allowed">
|
||||
{{else}}
|
||||
<input type="text" name="username" value="{{.FormUsername}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_username"}}">
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_password"}}</label>
|
||||
<input type="password" name="password" value="{{.FormPassword}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_password"}}">
|
||||
{{if .FormIsEdit}}
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "user_password_hint"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_display_name"}}</label>
|
||||
<input type="text" name="display_name" value="{{.FormDisplayName}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_email"}}</label>
|
||||
<input type="email" name="email" value="{{.FormEmail}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Gender -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_gender"}}</label>
|
||||
<select name="gender"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="other" {{if eq .FormGender "other"}}selected{{end}}>{{.TrGenderOther}}</option>
|
||||
<option value="male" {{if eq .FormGender "male"}}selected{{end}}>{{.TrGenderMale}}</option>
|
||||
<option value="female" {{if eq .FormGender "female"}}selected{{end}}>{{.TrGenderFemale}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_birthday"}}</label>
|
||||
<input type="date" name="birthday" value="{{.FormBirthday}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_role"}}</label>
|
||||
<select name="role"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.RoleAuthor}}" {{if eq .FormRole .RoleAuthor}}selected{{end}}>{{index .Tr "role_author"}}</option>
|
||||
<option value="{{.RoleAdmin}}" {{if eq .FormRole .RoleAdmin}}selected{{end}}>{{index .Tr "role_admin"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.StatusNormal}}" {{if eq .FormStatus .StatusNormal}}selected{{end}}>{{index .Tr "status_normal"}}</option>
|
||||
<option value="{{.StatusDisabled}}" {{if eq .FormStatus .StatusDisabled}}selected{{end}}>{{index .Tr "status_disabled"}}</option>
|
||||
<option value="{{.StatusLocked}}" {{if eq .FormStatus .StatusLocked}}selected{{end}}>{{index .Tr "status_locked"}}</option>
|
||||
<option value="{{.StatusUnactivated}}" {{if eq .FormStatus .StatusUnactivated}}selected{{end}}>{{index .Tr "status_unactivated"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "settings_save"}}
|
||||
</button>
|
||||
<a href="/admin/users"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
{{define "user_list"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-6xl 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 "user_list_title"}}</h2>
|
||||
<a href="/admin/users/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 "user_new"}}
|
||||
</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}}
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<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 "user_col_username"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_display"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_email"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_role"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_status"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_created"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "user_col_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .Users}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="" class="w-7 h-7 rounded-full object-cover">
|
||||
{{else}}
|
||||
<span class="w-7 h-7 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center text-xs font-bold">
|
||||
{{if .DisplayName}}{{slice .DisplayName 0 1}}{{else}}{{slice .Username 0 1}}{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{.Username}}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{if .Email}}{{.Email}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.RoleBadge}} px-2 py-1 rounded">{{.RoleLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.StatusBadge}} px-2 py-1 rounded">{{.StatusLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/admin/users/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
|
||||
<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="7" class="px-4 py-12 text-center text-gray-500">{{index .Tr "user_empty"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
+141
-20
@@ -5,6 +5,14 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
||||
{{if .SiteFavicon}}
|
||||
{{if .SiteFaviconIsURL}}
|
||||
<link rel="icon" href="{{.SiteFavicon}}">
|
||||
{{else}}
|
||||
<link rel="icon" href="/uploads/logos/{{.SiteFavicon}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||
@@ -14,29 +22,58 @@
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- 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"}}
|
||||
<div class="max-w-5xl mx-auto px-4 py-2.5 flex items-center justify-between">
|
||||
<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-7 w-auto">
|
||||
{{else}}
|
||||
<img src="/uploads/logos/{{.SiteLogo}}" alt="logo" class="h-7 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>
|
||||
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
<span>{{index .Tr "home"}}</span>
|
||||
</a>
|
||||
{{range .NavLinks}}
|
||||
<a href="{{.URL}}" {{if .OpenNew}}target="_blank" rel="noopener noreferrer"{{end}} class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
|
||||
<span>{{.Title $.Lang}}</span>
|
||||
{{if .OpenNew}}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .IsLoggedIn}}
|
||||
<!-- Avatar Dropdown (click to toggle) -->
|
||||
<div class="relative" id="avatarDropdown">
|
||||
<button type="button" id="avatarBtn" class="w-9 h-9 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm cursor-pointer" onclick="toggleDropdown()">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-4 h-4 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</span>
|
||||
</button>
|
||||
<div id="dropdownMenu" class="absolute right-0 mt-2 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 hidden">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "profile"}}
|
||||
</a>
|
||||
<a href="/my/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "my_articles"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_panel"}}
|
||||
</a>
|
||||
@@ -50,27 +87,73 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "login"}}</a>
|
||||
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span>{{index .Tr "login"}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<!-- Language Switcher -->
|
||||
<a href="?lang={{.SwitchLang}}" class="text-sm text-gray-400 hover:text-blue-600 transition-colors border border-gray-300 rounded px-2 py-1">
|
||||
{{index .Tr "lang_switch"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 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"}}
|
||||
</main>
|
||||
|
||||
<!-- 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"}}
|
||||
<footer class="bg-white border-t border-gray-200 py-4 mt-auto">
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<!-- Footer Actions Row -->
|
||||
<div class="flex justify-center items-center gap-3 mb-2">
|
||||
<!-- RSS Subscribe Button -->
|
||||
<div class="relative inline-block">
|
||||
<button onclick="toggleRSSMenu()" class="flex items-center gap-2 px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors text-sm font-medium">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/>
|
||||
</svg>
|
||||
<span>{{if eq .Lang "zh"}}RSS 订阅{{else}}RSS Feed{{end}}</span>
|
||||
</button>
|
||||
<!-- RSS Dropdown Menu -->
|
||||
<div id="rssMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||
<a href="/rss" target="_blank" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
<span>{{if eq .Lang "zh"}}打开 RSS 链接{{else}}Open RSS Link{{end}}</span>
|
||||
</a>
|
||||
<button onclick="copyRSSLink()" class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors text-left">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span id="copyText">{{if eq .Lang "zh"}}复制订阅链接{{else}}Copy Feed URL{{end}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<a href="?lang={{.SwitchLang}}" class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-blue-600 hover:bg-gray-50 transition-colors border border-gray-300 rounded-lg">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/>
|
||||
</svg>
|
||||
<span>{{index .Tr "lang_switch"}}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Footer Text -->
|
||||
<div class="text-center text-gray-500 text-xs">
|
||||
{{if .SiteFooterText}}{{.SiteFooterText}}{{else}}{{index .Tr "footer_text"}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
@@ -86,6 +169,44 @@
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// RSS Menu Functions
|
||||
function toggleRSSMenu() {
|
||||
var menu = document.getElementById('rssMenu');
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function copyRSSLink() {
|
||||
var rssURL = window.location.origin + '/rss';
|
||||
var copyText = document.getElementById('copyText');
|
||||
var originalText = copyText.textContent;
|
||||
var lang = '{{.Lang}}';
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(rssURL).then(function() {
|
||||
// Show success feedback
|
||||
copyText.textContent = lang === 'zh' ? '✓ 已复制!' : '✓ Copied!';
|
||||
copyText.parentElement.classList.add('text-green-600');
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(function() {
|
||||
copyText.textContent = originalText;
|
||||
copyText.parentElement.classList.remove('text-green-600');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy:', err);
|
||||
copyText.textContent = lang === 'zh' ? '✗ 复制失败' : '✗ Copy failed';
|
||||
});
|
||||
}
|
||||
|
||||
// Close RSS menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var rssButton = document.querySelector('[onclick="toggleRSSMenu()"]');
|
||||
var rssMenu = document.getElementById('rssMenu');
|
||||
if (rssButton && rssMenu && !rssButton.contains(e.target) && !rssMenu.contains(e.target)) {
|
||||
rssMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "article_not_found"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-3xl mx-auto px-4 py-24 text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-3">{{index .Tr "article_not_found"}}</h1>
|
||||
<a href="/" class="inline-block mt-4 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,256 @@
|
||||
{{define "article"}}
|
||||
{{template "header" .}}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
|
||||
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors">
|
||||
← {{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<a href="/admin/articles/{{.Article.ID}}/edit"
|
||||
class="inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-md border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors"
|
||||
title="{{index .Tr "article_edit"}}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{index .Tr "article_edit"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<h1 class="text-4xl font-extrabold text-gray-900 mb-3">{{.Article.Title}}</h1>
|
||||
<div class="flex items-center gap-3 text-sm text-gray-500 mb-8">
|
||||
{{if .Article.Author.Avatar}}
|
||||
<img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover">
|
||||
{{end}}
|
||||
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
|
||||
{{if .PublishedAt}}
|
||||
<span>·</span>
|
||||
<span>{{index .Tr "article_col_published"}}: {{.PublishedAt}}</span>
|
||||
{{end}}
|
||||
{{if .UpdatedAt}}
|
||||
<span>·</span>
|
||||
<span>{{index .Tr "article_last_updated"}}: {{.UpdatedAt}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Article.Cover}}
|
||||
<div class="rounded-xl overflow-hidden mb-8">
|
||||
<img src="{{.Article.Cover}}" alt="{{.Article.Title}}" class="w-full max-h-96 object-cover">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div id="articleBody" class="prose max-w-none text-gray-800"></div>
|
||||
</article>
|
||||
|
||||
{{if .CommentError}}
|
||||
<div class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{{.CommentError}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .CommentConfig}}
|
||||
{{if .CommentConfig.Enabled}}
|
||||
<!-- Comments -->
|
||||
<section id="comments" class="mt-16">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">{{index .Tr "comments_title"}}</h2>
|
||||
|
||||
{{if .CommentNotice}}
|
||||
<div id="commentNotice" class="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded-lg mb-6">{{.CommentNotice}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Comment list -->
|
||||
<div id="commentList" class="space-y-4 mb-10">
|
||||
{{range .Comments}}
|
||||
{{template "comment_node" .}}
|
||||
{{else}}
|
||||
<p class="text-gray-400 text-sm py-6 text-center">{{index $.Tr "comments_empty"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Comment form -->
|
||||
<div id="commentFormWrap" class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<div id="replyIndicator" class="hidden mb-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 flex items-center justify-between">
|
||||
<span id="replyTarget"></span>
|
||||
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
|
||||
</div>
|
||||
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
|
||||
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
|
||||
<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 "comments_name"}} *</label>
|
||||
<input type="text" name="name" maxlength="64" required value="{{.CommentForm.Name}}"
|
||||
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 "comments_email"}} *</label>
|
||||
<input type="email" name="email" maxlength="255" required value="{{.CommentForm.Email}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "comments_email_hint"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "comments_website"}}</label>
|
||||
<input type="url" name="website" maxlength="255" value="{{.CommentForm.Website}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 mb-2 text-sm">
|
||||
<button type="button" id="togglePreview" class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer">{{index .Tr "comments_preview"}}</button>
|
||||
<details class="relative">
|
||||
<summary class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer list-none">{{index .Tr "comments_emoji"}}</summary>
|
||||
<div id="emojiPicker" class="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-3 grid grid-cols-8 gap-1 w-72 text-xl"></div>
|
||||
</details>
|
||||
<details class="relative">
|
||||
<summary class="px-3 py-1 rounded border border-gray-300 hover:bg-gray-50 bg-white cursor-pointer list-none">{{index .Tr "comments_markdown_help"}}</summary>
|
||||
<div class="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-xs text-gray-600 w-56 space-y-1">
|
||||
<div><code>{{index .Tr "comments_markdown_bold"}}</code></div>
|
||||
<div><code>{{index .Tr "comments_markdown_italic"}}</code></div>
|
||||
<div><code>{{index .Tr "comments_markdown_code"}}</code></div>
|
||||
<div><code>{{index .Tr "comments_markdown_link"}}</code></div>
|
||||
<div><code>{{index .Tr "comments_markdown_quote"}}</code></div>
|
||||
</div>
|
||||
</details>
|
||||
<label class="ml-auto inline-flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="checkbox" name="is_private" value="1" {{if .CommentForm.IsPrivate}}checked{{end}} class="rounded border-gray-300 text-blue-600">
|
||||
<span>{{index .Tr "comments_private"}}</span>
|
||||
<span class="relative group">
|
||||
<span class="text-gray-400 cursor-help">?</span>
|
||||
<span class="absolute right-0 bottom-6 hidden group-hover:block bg-gray-800 text-white text-xs rounded px-2 py-1 w-48 z-30">{{index .Tr "comments_private_hint"}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea name="content" id="commentContent" maxlength="{{.MaxCommentLength}}" required
|
||||
placeholder="{{index .Tr "comments_content_ph"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 min-h-32 font-mono text-sm">{{.CommentForm.Content}}</textarea>
|
||||
<div id="previewBox" class="hidden w-full border border-gray-200 rounded-lg px-3 py-2 min-h-32 prose prose-sm max-w-none bg-gray-50"></div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<span id="charCount" class="text-xs text-gray-400"></span>
|
||||
<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 "comments_submit"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Article body
|
||||
var md = {{.Article.Content}};
|
||||
document.getElementById('articleBody').innerHTML = DOMPurify.sanitize(marked.parse(md));
|
||||
|
||||
marked.setOptions({ mangle: false, headerIds: false });
|
||||
|
||||
function renderCommentBodies() {
|
||||
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||
if (el.dataset.rendered) return;
|
||||
var raw = el.getAttribute('data-md') || '';
|
||||
el.innerHTML = DOMPurify.sanitize(marked.parse(raw));
|
||||
el.dataset.rendered = '1';
|
||||
});
|
||||
}
|
||||
renderCommentBodies();
|
||||
|
||||
// ---- Comment form: preview / emoji / reply ----
|
||||
// Auto-dismiss the one-time success/pending notice after a few seconds.
|
||||
(function () {
|
||||
var notice = document.getElementById('commentNotice');
|
||||
if (notice) {
|
||||
setTimeout(function () {
|
||||
notice.style.transition = 'opacity 500ms ease';
|
||||
notice.style.opacity = '0';
|
||||
setTimeout(function () { notice.remove(); }, 500);
|
||||
}, 4000);
|
||||
}
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var form = document.getElementById('commentForm');
|
||||
if (!form) return;
|
||||
var textarea = document.getElementById('commentContent');
|
||||
var previewBox = document.getElementById('previewBox');
|
||||
var togglePreview = document.getElementById('togglePreview');
|
||||
var parentIdInput = document.getElementById('parent_id');
|
||||
var replyIndicator = document.getElementById('replyIndicator');
|
||||
var replyTarget = document.getElementById('replyTarget');
|
||||
var cancelReply = document.getElementById('cancelReply');
|
||||
var charCount = document.getElementById('charCount');
|
||||
var formWrap = document.getElementById('commentFormWrap');
|
||||
var maxLen = {{.MaxCommentLength}};
|
||||
|
||||
function updateCharCount() {
|
||||
charCount.textContent = (textarea.value.length + ' / ' + maxLen);
|
||||
}
|
||||
textarea.addEventListener('input', updateCharCount);
|
||||
updateCharCount();
|
||||
|
||||
// Preview toggle
|
||||
togglePreview.addEventListener('click', function () {
|
||||
var previewing = previewBox.classList.toggle('hidden') === false;
|
||||
textarea.classList.toggle('hidden', previewing);
|
||||
if (previewing) {
|
||||
previewBox.innerHTML = DOMPurify.sanitize(marked.parse(textarea.value));
|
||||
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
|
||||
} else {
|
||||
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
|
||||
}
|
||||
});
|
||||
|
||||
// Emoji picker
|
||||
var emojis = ["😀","😁","😂","🤣","😊","😍","😎","🤔","😢","😭","😡","😴","👍","👎","👏","🙏","💪","🎉","❤️","🔥","💯","✅","❌","⭐","✨","🌹","☕","🎁","💡","📌"];
|
||||
var picker = document.getElementById('emojiPicker');
|
||||
emojis.forEach(function (e) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = e;
|
||||
btn.className = 'hover:bg-gray-100 rounded p-1 cursor-pointer bg-transparent border-none';
|
||||
btn.addEventListener('click', function () {
|
||||
insertAtCursor(textarea, e);
|
||||
textarea.focus();
|
||||
updateCharCount();
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
function insertAtCursor(field, text) {
|
||||
var start = field.selectionStart, end = field.selectionEnd;
|
||||
field.setRangeText(text, start, end, 'end');
|
||||
}
|
||||
|
||||
// Reply buttons (event delegation, works for dynamically nested nodes)
|
||||
document.getElementById('commentList').addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.reply-btn');
|
||||
if (!btn) return;
|
||||
var id = btn.getAttribute('data-id');
|
||||
var name = btn.getAttribute('data-name');
|
||||
parentIdInput.value = id;
|
||||
replyTarget.textContent = '{{index .Tr "comments_replying_to"}}'.replace('%s', name);
|
||||
replyIndicator.classList.remove('hidden');
|
||||
// Move the form under the replied comment for context.
|
||||
var node = document.getElementById('comment-' + id);
|
||||
if (node) {
|
||||
node.appendChild(formWrap);
|
||||
}
|
||||
formWrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
cancelReply.addEventListener('click', function () {
|
||||
parentIdInput.value = '';
|
||||
replyIndicator.classList.add('hidden');
|
||||
document.getElementById('comments').insertBefore(formWrap, document.getElementById('commentList').nextSibling);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "comment_node"}}
|
||||
<div id="comment-{{.Comment.ID}}" class="comment-node">
|
||||
<div class="flex items-start gap-3 py-3">
|
||||
{{if .UseGravatar}}
|
||||
<img src="{{.Comment.GravatarURL 48}}" alt="" class="w-10 h-10 rounded-full bg-gray-100 flex-shrink-0">
|
||||
{{else}}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0" style="background:{{.AvatarColor}}">{{.Comment.AuthorInitial}}</div>
|
||||
{{end}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
{{if .Comment.Website}}
|
||||
<a href="{{.Comment.Website}}" target="_blank" rel="nofollow ugc noopener" class="font-semibold text-gray-800 hover:text-blue-600">{{.Comment.AuthorName}}</a>
|
||||
{{else}}
|
||||
<span class="font-semibold text-gray-800">{{.Comment.AuthorName}}</span>
|
||||
{{end}}
|
||||
<span class="text-gray-400 text-xs">{{.RelTime}}</span>
|
||||
{{if .Comment.IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index .Tr "comments_private_badge"}}</span>{{end}}
|
||||
{{if eq .Comment.Status 0}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">{{index .Tr "comments_pending"}}</span>{{end}}
|
||||
</div>
|
||||
<div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Comment.Content}}"></div>
|
||||
<button type="button" class="reply-btn mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer"
|
||||
data-id="{{.Comment.ID}}" data-name="{{.Comment.AuthorName}}">{{index .Tr "comments_reply"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Children}}
|
||||
<div class="ml-6 sm:ml-10 pl-4 border-l-2 border-gray-100">
|
||||
{{range .Children}}
|
||||
{{template "comment_node" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
+382
-28
@@ -1,38 +1,392 @@
|
||||
{{define "home"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-5xl mx-auto px-4 py-20 text-center">
|
||||
<h1 class="text-5xl font-extrabold text-gray-900 mb-4">
|
||||
{{index .Tr "home_welcome"}}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
{{index .Tr "home_subtitle"}}
|
||||
</p>
|
||||
<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">
|
||||
{{index .Tr "home_sign_in"}}
|
||||
</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">
|
||||
{{index .Tr "home_to_dash"}}
|
||||
</a>
|
||||
|
||||
<!-- Search Box -->
|
||||
<section class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex justify-center">
|
||||
<form action="/search" method="get" class="w-full max-w-2xl">
|
||||
<div class="relative">
|
||||
<input type="text" name="q" placeholder="{{index .Tr "search_placeholder"}}"
|
||||
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-5xl mx-auto px-4 py-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post1_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post1_dsc"}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post2_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post2_dsc"}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post3_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post3_dsc"}}</p>
|
||||
<section class="max-w-5xl mx-auto px-4 py-20 text-center">
|
||||
<h1 class="text-5xl font-extrabold text-gray-900 mb-4 flex items-center justify-center gap-3">
|
||||
<svg class="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
|
||||
</svg>
|
||||
<span>{{if .SiteHomeWelcome}}{{.SiteHomeWelcome}}{{else}}{{index .Tr "home_welcome"}}{{end}}</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto flex items-center justify-center gap-2">
|
||||
<svg class="w-6 h-6 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span>{{if .SiteHomeSubtitle}}{{.SiteHomeSubtitle}}{{else}}{{index .Tr "home_subtitle"}}{{end}}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex gap-8">
|
||||
<!-- Main content (articles) -->
|
||||
<div class="flex-1">
|
||||
{{if .FilterTag}}
|
||||
<div class="mb-6 flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600">{{index .Tr "tag_filter"}}:</span>
|
||||
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full">
|
||||
{{if eq .Lang "zh"}}{{.FilterTag.NameZh}}{{else}}{{.FilterTag.NameEn}}{{end}}
|
||||
</span>
|
||||
<a href="/" class="text-blue-600 hover:text-blue-800">{{index .Tr "tag_all"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
|
||||
|
||||
<!-- Articles Container -->
|
||||
<div id="articlesContainer" class="space-y-6">
|
||||
{{range .Articles}}
|
||||
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
|
||||
<a href="/article/{{.Slug}}" class="block article-link">
|
||||
<div class="article-content">
|
||||
<!-- Cover will be positioned by JS -->
|
||||
{{if .Cover}}
|
||||
<div class="article-cover">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}" class="article-image" loading="lazy">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="article-text p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">
|
||||
{{.Title}}
|
||||
{{if .IsTop}}<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index $.Tr "article_is_top"}}</span>{{end}}
|
||||
</h3>
|
||||
{{if .Summary}}
|
||||
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
|
||||
{{end}}
|
||||
{{if .Tags}}
|
||||
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
|
||||
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{.ViewCount}}
|
||||
</span>
|
||||
<span class="flex items-center gap-1" data-article-id="{{.ID}}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
<span class="comment-count">{{index $.CommentCounts .ID}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="text-center text-gray-500 py-12">{{index .Tr "home_no_posts"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loadingIndicator" class="hidden text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="text-gray-500 mt-2">{{if eq .Lang "zh"}}加载中...{{else}}Loading...{{end}}</p>
|
||||
</div>
|
||||
|
||||
<!-- No more articles message -->
|
||||
<div id="noMoreArticles" class="hidden text-center text-gray-500 py-8">
|
||||
{{if eq .Lang "zh"}}没有更多文章了{{else}}No more articles{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (tags) -->
|
||||
<aside class="w-72 flex-shrink-0 hidden lg:block">
|
||||
<div class="sticky top-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
|
||||
{{if .Tags}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.Slug}}"
|
||||
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
|
||||
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
|
||||
<span class="text-xs text-gray-500">({{.Count}})</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Article card layouts */
|
||||
.article-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Default: no cover or loading */
|
||||
.article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Horizontal layout (landscape image on top) */
|
||||
.article-card.layout-horizontal .article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-card.layout-horizontal .article-cover {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.article-card.layout-horizontal .article-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Vertical layout (portrait/square image on left) */
|
||||
.article-card.layout-vertical .article-content {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-cover {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.article-card.layout-vertical .article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-cover {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
|
||||
const container = document.getElementById('articlesContainer');
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const noMoreArticles = document.getElementById('noMoreArticles');
|
||||
|
||||
// Determine layout based on image aspect ratio
|
||||
function determineLayout(card) {
|
||||
const cover = card.getAttribute('data-cover');
|
||||
if (!cover) {
|
||||
return; // No cover, default column layout
|
||||
}
|
||||
|
||||
const img = card.querySelector('.article-image');
|
||||
if (!img) return;
|
||||
|
||||
// Wait for image to load
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', function() {
|
||||
applyLayout(card, img);
|
||||
});
|
||||
} else {
|
||||
applyLayout(card, img);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayout(card, img) {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
const aspectRatio = width / height;
|
||||
|
||||
// If aspect ratio > 1.2, use horizontal (landscape)
|
||||
// Otherwise use vertical (portrait/square on left)
|
||||
if (aspectRatio > 1.2) {
|
||||
card.classList.add('layout-horizontal');
|
||||
} else {
|
||||
card.classList.add('layout-vertical');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize existing articles
|
||||
document.querySelectorAll('.article-card').forEach(determineLayout);
|
||||
|
||||
// Load more articles
|
||||
function loadMoreArticles() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
isLoading = true;
|
||||
loadingIndicator.classList.remove('hidden');
|
||||
|
||||
fetch('/api/articles?page=' + (currentPage + 1))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.articles && data.articles.length > 0) {
|
||||
currentPage++;
|
||||
data.articles.forEach(article => {
|
||||
const articleHTML = createArticleCard(article);
|
||||
container.insertAdjacentHTML('beforeend', articleHTML);
|
||||
});
|
||||
|
||||
// Determine layout for new cards
|
||||
const newCards = container.querySelectorAll('.article-card:not(.layout-horizontal):not(.layout-vertical)');
|
||||
newCards.forEach(determineLayout);
|
||||
}
|
||||
|
||||
hasMore = data.hasMore;
|
||||
|
||||
if (!hasMore) {
|
||||
noMoreArticles.classList.remove('hidden');
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
loadingIndicator.classList.add('hidden');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading articles:', error);
|
||||
isLoading = false;
|
||||
loadingIndicator.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Create article card HTML
|
||||
function createArticleCard(article) {
|
||||
const topBadge = article.is_top ?
|
||||
'<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index .Tr "article_is_top"}}</span>' : '';
|
||||
|
||||
const coverHTML = article.cover ?
|
||||
`<div class="article-cover">
|
||||
<img src="${article.cover}" alt="${escapeHtml(article.title)}" class="article-image" loading="lazy">
|
||||
</div>` : '';
|
||||
|
||||
const summaryHTML = article.summary ?
|
||||
`<p class="text-gray-500 text-sm line-clamp-3 mb-3">${escapeHtml(article.summary)}</p>` : '';
|
||||
|
||||
const commentCount = article.comment_count || 0;
|
||||
const viewCount = article.view_count || 0;
|
||||
|
||||
return `
|
||||
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="${article.cover || ''}">
|
||||
<a href="/article/${article.slug}" class="block article-link">
|
||||
<div class="article-content">
|
||||
${coverHTML}
|
||||
<div class="article-text p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">
|
||||
${escapeHtml(article.title)}
|
||||
${topBadge}
|
||||
</h3>
|
||||
${summaryHTML}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-blue-600 font-medium">{{index .Tr "home_read_more"}} →</span>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
${viewCount}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
${commentCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Infinite scroll
|
||||
function checkScroll() {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const threshold = document.documentElement.scrollHeight - 500;
|
||||
|
||||
if (scrollPosition >= threshold && !isLoading && hasMore) {
|
||||
loadMoreArticles();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle scroll event
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
scrollTimeout = setTimeout(checkScroll, 100);
|
||||
});
|
||||
|
||||
// Initial check in case content is short
|
||||
setTimeout(checkScroll, 500);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
{{index .Tr "login_submit"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{if .AllowRegistration}}
|
||||
<div class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600">{{index .Tr "login_no_account"}}</span>
|
||||
<a href="/register" class="text-sm text-blue-600 hover:text-blue-700 font-medium ml-1">{{index .Tr "login_register_link"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{define "register"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-md mx-auto px-4 py-20">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "register_title"}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/register" method="post" class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "register_ph_username"}}"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_username_hint"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_display_name"}}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "register_ph_display_name"}}"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_display_name_hint"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_email"}}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "register_ph_email"}}"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_email_hint"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_password"}}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="6"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "register_ph_password"}}"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_password_hint"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_confirm_password"}}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
required
|
||||
minlength="6"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "register_ph_confirm_password"}}"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer"
|
||||
>
|
||||
{{index .Tr "register_submit"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600">{{index .Tr "register_have_account"}}</span>
|
||||
<a href="/login" class="text-sm text-blue-600 hover:text-blue-700 font-medium ml-1">{{index .Tr "register_login_link"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,241 @@
|
||||
{{define "search"}}
|
||||
{{template "header" .}}
|
||||
|
||||
<!-- Search Box -->
|
||||
<section class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex justify-center">
|
||||
<form action="/search" method="get" class="w-full max-w-2xl">
|
||||
<div class="relative">
|
||||
<input type="text" name="q" value="{{.SearchKeyword}}" placeholder="{{index .Tr "search_placeholder"}}"
|
||||
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex gap-8">
|
||||
<!-- Main content (search results) -->
|
||||
<div class="flex-1">
|
||||
{{if .SearchKeyword}}
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">{{index .Tr "search_results_for"}}</h2>
|
||||
<p class="text-gray-600 mb-8">"{{.SearchKeyword}}" - {{if eq .Lang "zh"}}找到{{else}}Found{{end}} {{len .Articles}} {{if eq .Lang "zh"}}篇文章{{else}}articles{{end}}</p>
|
||||
{{else}}
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "search_title"}}</h2>
|
||||
{{end}}
|
||||
|
||||
<!-- Articles Container -->
|
||||
<div class="space-y-6">
|
||||
{{range .Articles}}
|
||||
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
|
||||
<a href="/article/{{.Slug}}" class="block article-link">
|
||||
<div class="article-content">
|
||||
<!-- Cover will be positioned by JS -->
|
||||
{{if .Cover}}
|
||||
<div class="article-cover">
|
||||
<img src="{{.Cover}}" alt="{{.Title}}" class="article-image" loading="lazy">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="article-text p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">
|
||||
{{.Title}}
|
||||
{{if .IsTop}}<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index $.Tr "article_is_top"}}</span>{{end}}
|
||||
</h3>
|
||||
{{if .Summary}}
|
||||
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
|
||||
{{end}}
|
||||
{{if .Tags}}
|
||||
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
|
||||
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{.ViewCount}}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
<span class="comment-count">{{index $.CommentCounts .ID}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
{{if .SearchKeyword}}
|
||||
{{index .Tr "search_no_results"}}
|
||||
{{else}}
|
||||
{{index .Tr "home_no_posts"}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (tags) -->
|
||||
<aside class="w-72 flex-shrink-0 hidden lg:block">
|
||||
<div class="sticky top-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
|
||||
{{if .Tags}}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.Slug}}"
|
||||
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
|
||||
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
|
||||
<span class="text-xs text-gray-500">({{.Count}})</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Article card layouts */
|
||||
.article-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Default: no cover or loading */
|
||||
.article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Horizontal layout (landscape image on top) */
|
||||
.article-card.layout-horizontal .article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-card.layout-horizontal .article-cover {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.article-card.layout-horizontal .article-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Vertical layout (portrait/square image on left) */
|
||||
.article-card.layout-vertical .article-content {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-cover {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.article-card.layout-vertical .article-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-card.layout-vertical .article-cover {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Determine layout based on image aspect ratio
|
||||
function determineLayout(card) {
|
||||
const cover = card.getAttribute('data-cover');
|
||||
if (!cover) {
|
||||
return; // No cover, default column layout
|
||||
}
|
||||
|
||||
const img = card.querySelector('.article-image');
|
||||
if (!img) return;
|
||||
|
||||
// Wait for image to load
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', function() {
|
||||
applyLayout(card, img);
|
||||
});
|
||||
} else {
|
||||
applyLayout(card, img);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayout(card, img) {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
const aspectRatio = width / height;
|
||||
|
||||
// If aspect ratio > 1.2, use horizontal (landscape)
|
||||
// Otherwise use vertical (portrait/square on left)
|
||||
if (aspectRatio > 1.2) {
|
||||
card.classList.add('layout-horizontal');
|
||||
} else {
|
||||
card.classList.add('layout-vertical');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize existing articles
|
||||
document.querySelectorAll('.article-card').forEach(determineLayout);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,103 @@
|
||||
{{define "my_article_form"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
{{if .SessionToken}}
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
{{end}}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_slug_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_content"}}</label>
|
||||
<textarea id="content" name="content" rows="20"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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 "article_save"}}
|
||||
</button>
|
||||
<a href="/my/articles"
|
||||
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_cancel"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content'),
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
|
||||
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "my_articles"}}
|
||||
{{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 "my_articles_title"}}</h2>
|
||||
<a href="/my/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="/my/articles/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/my/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}}
|
||||
Reference in New Issue
Block a user