Compare commits

...
27 Commits
Author SHA1 Message Date
kevin 7d9ccdde75 up 2026-06-24 11:46:08 +08:00
kevin 2875918289 修改运行路径 2026-06-24 11:32:45 +08:00
kevin 1fc933e014 修复CDN环境下获取真实客户端IP的问题 2026-06-24 11:18:56 +08:00
kevin fe69a2e38b up 2026-06-23 15:16:50 +08:00
kevin d8779ed5bf 修复安装问题 2026-06-23 14:57:36 +08:00
kevin a316932d3f up 2026-06-23 11:23:33 +08:00
kevin 5f8c7be5ac up 2026-06-23 10:45:37 +08:00
kevin fe72cbaf9d 删除过期文件 2026-06-23 10:37:08 +08:00
kevinandClaude Fable 5 d9bb91af00 feat: add navigation links management and user registration
Features:
- Custom navigation links management in admin settings
- Multi-language support for navigation links (Chinese/English)
- Control link behavior (open in new window)
- Sort order and enable/disable toggle for nav links
- User registration system with validation
- Admin can enable/disable user registration
- Registration form with username, display name, email, password
- Link to registration from login page

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 21:01:38 +08:00
kevinandClaude Fable 5 2bdc368399 feat: implement tag management and search functionality
Features:
- Tag system with multi-language support (Chinese/English)
- Auto-create tags when associating with articles
- Tag filtering on homepage with visual indicators
- Full-text search across title, summary, and content
- Tag cloud sidebar showing article counts
- Search page with results display

Technical Implementation:
- Database models: Tag, ArticleTag (many-to-many)
- Tag count caching for performance
- Automatic tag slug generation
- Responsive design with mobile support
- I18n support for all new UI elements

Bug Fix:
- Fixed SQL column ambiguity error in tag filtering
- Added table prefix to ORDER BY clause in publishedArticleOrder

Routes:
- GET /search - Search results page
- GET /?tag=<slug> - Filter articles by tag
- GET /api/articles?tag=<slug> - API with tag filter support

Templates:
- Added tag input field to article create/edit form
- Added search box to homepage header
- Added tag cloud sidebar on homepage and search page
- Created new search results page template

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:59:32 +08:00
kevinandClaude Fable 5 b4983f1d4d feat: add icons to homepage header and navigation links
- Add blog icon to homepage welcome title
  - Newspaper/blog icon (48px, blue)
  - Centered with flex layout

- Add lightning icon to homepage subtitle
  - Lightning bolt icon (24px, gray)
  - Represents speed and modern tech

- Add home icon to navigation home link
  - House icon with clean design
  - Consistent styling with other nav elements

- Add login icon to navigation login link
  - Login/sign-in icon
  - Only shown when user is not logged in

- Use Heroicons SVG for consistency
- Responsive and accessible design
- Smooth hover transitions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:24:30 +08:00
kevinandClaude Fable 5 b0ca76e8dc feat: add custom publish time and last updated time display
- Add custom publish time field to article create/edit forms
  - Support datetime-local input for manual time setting
  - Auto-set on first publish if left blank
  - Works for both admin and user article forms

- Display last updated time on article detail page
  - Show both published time and last updated time
  - Auto-maintained by GORM on each update
  - Format: YYYY-MM-DD HH:MM

- Add i18n support for new fields
  - article_published_at: Published Time / 发布时间
  - article_published_at_hint: Leave blank to auto-set on publish / 留空则在发布时自动设置
  - article_last_updated: Last Updated / 最后更新

- Update handlers and templates
  - handlers/article.go: Add parsePublishedAt/formatPublishedAt functions
  - handlers/home.go: Add formatUpdateTime function
  - handlers/my_articles.go: Support custom publish time in user articles
  - templates: Add datetime-local inputs and time display

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:20:19 +08:00
kevinandClaude Fable 5 b600eac21c feat: add favicon settings to site configuration
- Add Favicon field to SiteSetting model with FaviconIsURL() helper
- Implement favicon upload and management in site settings page
- Support both external URL and local file upload for favicon
- Add favicon display in page header with automatic URL detection
- Add i18n translations for favicon settings (EN/ZH)
- Update middleware and helpers to pass favicon data to templates
- Include migration script for existing databases

Users can now customize their site's favicon from /admin/settings/site
by either providing an external URL or uploading a local image file
(recommended formats: .ico, .png, .svg, 16x16 or 32x32 pixels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:09:05 +08:00
kevinandClaude Fable 5 da7a39c1c8 feat: implement comprehensive reading analytics system with bot detection
Features:
- Add article view tracking with automatic deduplication (per user/IP)
- Implement intelligent bot detection (35+ patterns: Google, Bing, Baidu, GPTBot, etc)
- Create admin analytics dashboard with statistics and filtering
- Display view count and comment count on article cards
- Add search-based article filter (replaced dropdown for scalability)

Analytics Dashboard:
- Global stats: total views, human/bot views, unique IPs/users
- Top 20 articles ranking with detailed metrics
- Detailed view records with time, article, user, IP, user-agent
- Filters: article title search, IP search, show/hide bots
- Pagination support (50 records per page)

Technical Implementation:
- Async view recording (non-blocking)
- Dual deduplication (application + database layer)
- Database indexes for performance optimization
- Batch query for comment counts
- Full i18n support (Chinese/English)

Files Added:
- models/article_view.go: View tracking model
- models/bot_detector.go: Bot detection logic
- handlers/admin_analytics.go: Analytics page handler
- templates/admin/analytics_views.html: Analytics UI
- test_analytics.sh: Testing script
- Documentation: implementation guide, usage guide, changelog

Files Modified:
- handlers/home.go: Add view recording and comment count queries
- models/db.go: Add ArticleView to auto-migration
- main.go: Add analytics routes
- i18n/i18n.go: Add 35+ translation keys
- templates/admin/dashboard.html: Add analytics entry link
- templates/pages/home.html: Display view/comment counts on cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:57:12 +08:00
kevinandClaude Fable 5 5ec783e471 feat: implement waterfall layout with smart image positioning and infinite scroll
- Add single-column layout for homepage articles
- Implement smart image positioning based on aspect ratio:
  * Landscape images (ratio > 1.2) displayed on top
  * Portrait/square images (ratio ≤ 1.2) displayed on left side
- Add infinite scroll with lazy loading
- Create new API endpoint /api/articles for pagination
- Add loading indicator and 'no more articles' message
- Optimize performance with image lazy loading and scroll throttling
- Add responsive layout for mobile devices

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:31:18 +08:00
kevin 7047b33358 up 2026-06-22 19:07:13 +08:00
kevinandClaude Fable 5 eb9a65097e fix: redirect users based on role after login
- Admin users redirect to /admin dashboard
- Regular users redirect to home page /
- Prevents authorization errors for non-admin users

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:05:39 +08:00
kevinandClaude Fable 5 aca4983447 fix: allow clearing display name in profile settings
- Remove restriction that prevented setting display_name to empty
- Users can now clear display name to fall back to username display
- Trim whitespace from display_name input

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:02:59 +08:00
kevinandClaude Fable 5 95cbd0240f feat: display user's display name next to avatar in navigation
- Show display name (or username as fallback) next to avatar
- Improve UI readability and user experience
- Avatar and name together act as dropdown trigger

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:01:24 +08:00
kevinandClaude Fable 5 d64c839b10 feat: add role-based access control and user article management
- Add AdminRequired middleware to all /admin routes for proper authorization
- Restrict admin panel, comments, users, settings to admin role only
- Create separate /my/articles routes for regular users to manage their own articles
- Add MyArticlesPage handler for users to view/edit/delete their own content
- Update navigation menus with role-based visibility
- Admin dropdown shows only 'Admin Panel' link, other features in dashboard
- Regular users see 'My Articles' link in profile dropdown
- Add i18n translations for 'my_articles' and 'comment_manage' keys
- Fix permission boundary issues where author role could access admin settings

Security improvements:
- Platform settings now require admin role
- Comment moderation requires admin role
- User management requires admin role
- Regular users can only manage their own articles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 18:49:33 +08:00
kevin cefaeac618 feat: add admin user management with role-based access and self-protection
- Add AdminRequired middleware for admin-role routes
- Add login status check to reject disabled/locked accounts
- Add CRUD handlers (list, create, edit, delete) for users
- Add user_list and user_form templates with role/status badges
- Add nav entry and dashboard button for user management
- Add dynamic user count on admin dashboard
- Fix userIDFromSession to handle all numeric session types
- Self-protection: cannot delete/disable self or demote last admin
- Add EN/ZH i18n keys for all user management strings
2026-06-22 18:11:39 +08:00
kevinandClaude Fable 5 6139b3ef2c feat: add article comment system with moderation
- Comment model with nested replies (ParentID), dual authorship
  (logged-in UserID / anonymous GuestToken cookie), email hash for
  Gravatar, private flag, and moderation status
- CommentConfig singleton (enabled / allow guest / guest-require-approval
  / use Gravatar) cached like the other platform config
- Markdown comments with built-in emoji picker, preview, and markdown
  help; rendered client-side via marked + DOMPurify, with server-side
  HTML/dangerous-scheme stripping as a first XSS defense
- Private comments visible only to admin and the author; pending
  comments visible only to admin and the author
- Admin moderation list (pending/approved/rejected/all tabs) with
  approve/reject/delete and a pending-count badge
- Comment settings page with the four toggles
- One-time session flash notice (auto-dismissed after 4s) so the
  "comment posted" banner no longer persists across refreshes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 17:21:54 +08:00
kevinandClaude Fable 5 1f1123acf8 fix: generate non-empty slugs for non-ASCII titles
The slug regex stripped everything that is not a-z0-9, so Chinese or
punctuation-only titles produced an empty slug (stored as ""), which
broke the unique index and article URLs.

generateSlug now keeps ASCII word characters and returns "" when the
title yields no URL-safe ASCII (e.g. CJK or punctuation only). Both the
create and update handlers fall back to a "post-<id>" slug in that case;
on create a temporary token-based placeholder is refined to post-<id>
once the row exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 21:14:23 +08:00
kevinandClaude Fable 5 e872c08f3b feat: article attachments with content-addressed dedup
Add an attachments table and AJAX upload/management on the article
create and edit pages.

- Attachment model with article_id (0 while pending on create),
  session_token ownership, and SHA-256 content-addressed stored_name
- Upload validates via the platform policy (switch/type/size) and
  deduplicates on disk by content hash
- Plan-A binding: attachments uploaded before an article exists are
  owned by a session token and bound to the new article on save
- Delete soft-removes the record and drops the disk file only when no
  remaining rows reference it (reference counting for deduped files)
- Edit page loads existing attachments via JSON list endpoint
- Row actions: insert into body (markdown image/link), set as cover
  (image only), and delete
- Download URLs use the configured default download base URL when set,
  else the local /uploads path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 21:02:09 +08:00
kevinandClaude Fable 5 0c5bcde7da feat: make home welcome/subtitle configurable and fix settings render
Add home_welcome and home_subtitle fields (zh/en) to site_settings so
the home page heading and subtitle are editable from the admin panel,
falling back to the i18n defaults when empty. Wire them through the
config cache, SetUserContext, DefaultData, and home.html.

Fix the /admin/settings/site page rendering blank: Go templates cannot
invoke methods on an interface{}-wrapped struct, so pre-compute
SiteLogoIsURL in the handler instead of calling .Site.LogoIsURL() in
the template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 20:41:45 +08:00
kevinandClaude Fable 5 e3367e6e12 feat: add platform configuration tables and admin settings
Add four DB-backed configuration tables for site-wide settings:
site_settings (logo, top-left title, header/footer text with zh/en
variants), upload_configs (master switch, default size, storage dir),
upload_file_types (per-extension whitelist with per-type size limits),
and download_baseurls (multiple download sources with priority/default).

- Models, AutoMigrate, and first-run seed (18 common file types)
- Process-level config cache warmed at startup, refreshed on admin save
- SetUserContext injects cached site settings into templates
- base.html renders logo (local upload or external URL), title, header
  banner, and footer with i18n fallback
- Upload validator drives avatar uploads (form + AJAX) through the
  configured switch/type/size policy
- Admin pages for site/upload/download settings with i18n keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 20:32:28 +08:00
kevinandClaude Fable 5 b474ee009a feat: add article management (list, edit, delete)
- Add admin article list page at /admin/articles with status badges,
  pin marker, publish time, and edit/delete actions
- Share the create/edit form: dynamic action URL, prefilled fields,
  is_top checkbox state, and create-vs-edit heading
- Add ArticleUpdate/ArticleDelete handlers (soft-delete via DeletedAt);
  stamp PublishedAt on first publish
- Add "Manage Articles" entry on the dashboard
- Hide home page sign-in/dashboard buttons
- Add EN/ZH i18n keys for the management UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 20:13:39 +08:00
60 changed files with 7813 additions and 290 deletions
-23
View File
@@ -1,23 +0,0 @@
{
"permissions": {
"allow": [
"Bash(curl -s -c /tmp/cookies.txt -L -X POST http://localhost:8099/login -d \"username=admin&password=admin\" -o /dev/null -w \"login final HTTP %{http_code}\\\\n\")",
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o /tmp/dash.html -w \"HTTP %{http_code}\\\\n\")",
"Read(//tmp/**)",
"Bash(sed -n 's/.*\\\\\\(text-3xl font-bold text-gray-900 mt-1\">1\\\\n\\\\\\).*//p' /tmp/dash.html)",
"Bash(perl -0777 -ne 'while\\(/dash_posts.*?text-3xl[^>]*>\\(.*?\\)<\\\\/p>/sg\\){print \"POSTS BLOCK: $1\\\\n\"}' /tmp/dash.html)",
"Bash(python -c ' *)",
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o dash.html)",
"Bash(rm -f dash.html)",
"Bash(curl -s http://localhost:8099/article/123 -o detail.html -w \"detail HTTP %{http_code}\\\\n\")",
"Bash(rm -f detail.html)",
"Bash(cp /tmp/cfg_backup.yaml win/etc/blog_go/config.yaml)",
"Bash(pkill -f blogtest.exe)",
"Bash(pkill -f \"blog_go.exe\")",
"Bash(cd c:/Users/wuwen/Documents/project/go_blog && rm -f /tmp/blogtest.exe && git status --short && echo \"--- diff stat ---\" && git diff --stat)"
],
"additionalDirectories": [
"\\tmp"
]
}
}
+3
View File
@@ -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/
+162 -65
View File
@@ -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
+27 -4
View File
@@ -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,
},
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"
+9
View File
@@ -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
+5
View File
@@ -25,6 +25,11 @@ func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
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)
}
}
+123
View File
@@ -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)
}
}
+150
View File
@@ -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")
}
}
+385
View File
@@ -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")
}
}
+417 -86
View File
@@ -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,112 +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 {
c.Redirect(http.StatusFound, "/login")
return
}
// The session stores user.ID, which is a gorm uint — but be defensive
// across int / uint / int64 storage so a type mismatch never panics.
var authorID uint
switch v := userID.(type) {
case uint:
authorID = v
case int:
authorID = uint(v)
case int64:
authorID = uint(v)
case float64:
authorID = uint(v)
default:
authorID, ok := sessionAuthorID(c)
if !ok {
c.Redirect(http.StatusFound, "/login")
return
}
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: authorID,
Title: title,
Slug: slug,
Summary: summary,
Content: content,
Cover: cover,
Title: f.Title,
Slug: f.Slug,
Summary: f.Summary,
Content: f.Content,
Cover: f.Cover,
Status: status,
IsTop: isTop,
IsTop: f.IsTop,
PublishedAt: publishedAt,
}
if err := db.Create(&article).Error; err != nil {
data := DefaultData(c)
data["Title"] = tr["article_create_title"]
data["Error"] = tr["article_error"]
data["FormTitle"] = title
data["FormSlug"] = slug
data["FormSummary"] = summary
data["FormContent"] = content
data["FormCover"] = cover
data["FormStatus"] = statusStr
c.HTML(http.StatusOK, "article_create", data)
renderArticleForm(c, db, f, tr["article_error"])
return
}
// 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")
}
}
+232
View File
@@ -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
}
+120
View File
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -19,6 +20,11 @@ func LoginPage() gin.HandlerFunc {
if c.Query("error") == "1" {
data["Error"] = tr["login_error"]
}
// Check if registration is allowed from site settings
siteSetting, _ := c.Get("site_setting")
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
data["AllowRegistration"] = s.AllowRegistration
}
c.HTML(http.StatusOK, "login", data)
}
}
@@ -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
}
// 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, "/")
}
}
+366
View File
@@ -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")
}
}
+39
View File
@@ -1,6 +1,8 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
)
@@ -16,6 +18,17 @@ 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,
@@ -26,6 +39,17 @@ func DefaultData(c *gin.Context) gin.H {
"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()
}
+337 -3
View File
@@ -1,9 +1,12 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go_blog/models"
@@ -12,7 +15,7 @@ import (
// publishedArticleOrder is the ordering used to list published articles:
// pinned first, then by most recent publish/creation time.
const publishedArticleOrder = "is_top DESC, published_at DESC, created_at DESC"
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 {
@@ -21,17 +24,159 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
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
db.Where("status = ?", models.ArticlePublished).
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) {
@@ -53,18 +198,48 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
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
@@ -75,3 +250,162 @@ func formatPublishTime(publishedAt *time.Time) string {
}
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)
}
}
+177
View File
@@ -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
View File
@@ -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,11 +92,22 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
if err == nil {
defer file.Close()
// Determine file extension.
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
ext = ".jpg"
// Validate against the platform upload policy (switch + type + size).
check := ValidateUpload(header)
if !check.OK {
session.Save()
reason := "?error=upload"
if !models.GetUploadConfig().Enabled {
reason = "?error=upload_disabled"
} else if check.Type != nil {
reason = fmt.Sprintf("?error=size&max=%s", formatSize(check.MaxSize))
}
c.Redirect(http.StatusFound, "/profile"+reason)
return
}
// Determine file extension (validated to be in the whitelist).
ext := strings.ToLower(filepath.Ext(header.Filename))
// Save under storagePath/avatars/.
avatarDir := filepath.Join(storagePath, "avatars")
@@ -173,11 +192,23 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
}
defer file.Close()
// Validate extension.
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
ext = ".jpg"
// Validate against the platform upload policy (switch + type + size).
check := ValidateUpload(header)
if !check.OK {
if !models.GetUploadConfig().Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "uploads are disabled"})
return
}
if check.Type != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// Determine file extension (validated to be in the whitelist).
ext := strings.ToLower(filepath.Ext(header.Filename))
// Read file bytes for image processing.
imgBytes, err := io.ReadAll(file)
+181
View File
@@ -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
}
+531
View File
@@ -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)
}
+72
View File
@@ -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])
}
+630
View File
@@ -46,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",
@@ -72,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",
@@ -85,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",
@@ -99,17 +135,30 @@ 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.",
@@ -118,6 +167,272 @@ var translations = map[Lang]map[string]string{
"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: {
// 导航
@@ -151,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": "后台管理",
@@ -175,6 +522,7 @@ var translations = map[Lang]map[string]string{
// 个人中心下拉菜单 & 页面
"profile": "个人信息",
"admin_panel": "后台管理",
"my_articles": "我的文章",
"profile_title": "编辑个人信息",
"profile_avatar": "头像",
"profile_change_avatar": "更换头像",
@@ -188,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": "其他",
@@ -202,17 +553,30 @@ 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": "创建文章失败。",
@@ -221,6 +585,272 @@ var translations = map[Lang]map[string]string{
"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": "加载更多",
},
}
+98
View File
@@ -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}"
+132 -5
View File
@@ -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{
@@ -47,18 +57,84 @@ func main() {
// 8. Register routes.
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(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.
@@ -70,10 +146,61 @@ func main() {
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
}
// 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.
addr := fmt.Sprintf(":%s", cfg.Port)
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 server: %v", err)
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 {}
}
+40
View File
@@ -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()
}
}
+1
View File
@@ -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.
+17
View File
@@ -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"
}
+48
View File
@@ -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
}
+48
View File
@@ -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
}
+38
View File
@@ -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
}
+106
View File
@@ -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]))
}
+31
View File
@@ -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,
}
}
+133
View File
@@ -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
View File
@@ -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)
+37
View File
@@ -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
}
+98
View File
@@ -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)
}
}
+123
View File
@@ -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
View File
@@ -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
}
+74
View File
@@ -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"
}
+9
View File
@@ -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';
+170
View File
@@ -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}}
+156 -3
View File
@@ -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 + ')'
: '[' + 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}}
+60
View File
@@ -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}}
+94
View File
@@ -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}}
+25 -1
View File
@@ -30,7 +30,7 @@
</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>
@@ -38,6 +38,7 @@
<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 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">
@@ -45,6 +46,29 @@
</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" .}}
+49
View File
@@ -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}}
+104
View File
@@ -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}}
+200
View File
@@ -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}}
+150
View File
@@ -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}}
+140
View File
@@ -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}}
+105
View File
@@ -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}}
+79
View File
@@ -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}}
+135 -14
View File
@@ -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()">
<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-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<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>
{{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 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}}
</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>
+222 -5
View File
@@ -2,11 +2,24 @@
{{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">
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors mb-6">
<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>
@@ -15,7 +28,14 @@
<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>{{.PublishedAt}}</span>{{end}}
{{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}}
@@ -26,13 +46,210 @@
<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.Content}} is emitted by html/template as a JSON-encoded JS
// string, safely handling quotes, newlines, and HTML special characters.
// Article body
var md = {{.Article.Content}};
document.getElementById('articleBody').innerHTML = marked.parse(md);
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" .}}
+33
View File
@@ -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}}
+377 -30
View File
@@ -1,45 +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">
{{range .Articles}}
<a href="/article/{{.Slug}}"
class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
{{if .Cover}}
<div class="aspect-video w-full overflow-hidden bg-gray-100">
<img src="{{.Cover}}" alt="{{.Title}}" class="w-full h-full object-cover">
<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}}
<div class="p-6 flex flex-col flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{.Title}}
<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-2">{{.Summary}}</p>{{end}}
<span class="mt-4 text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
</div>
{{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>
{{else}}
<div class="col-span-full text-center text-gray-500 py-12">{{index .Tr "home_no_posts"}}</div>
{{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}}
+7
View File
@@ -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" .}}
+90
View File
@@ -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}}
+241
View File
@@ -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}}
+103
View File
@@ -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}}
+60
View File
@@ -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}}