feat: initial blog engine with multi-language, profile, and role system
- Gin + GORM + Tailwind CSS blog engine - OS-aware config (Linux /etc/blog_go/, Windows ./win/etc/blog_go/) - SQLite by default, MySQL support via config - Auto-migrate database + seed admin user on first run - Session-based auth (login/logout) - i18n: Chinese/English with auto-detect + manual switch - Avatar dropdown nav with click-to-toggle menu - Profile page: avatar upload, edit info, change password - Role system: admin (full access) and author (profile only) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
# Binary
|
||||
go_blog
|
||||
go_blog.exe
|
||||
main.exe
|
||||
|
||||
# Auto-generated runtime data (config + database + uploads)
|
||||
win/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.out
|
||||
@@ -0,0 +1,128 @@
|
||||
# Go Blog
|
||||
|
||||
A simple, fast blog engine built with Go, Gin, and Tailwind CSS. Supports SQLite and MySQL, with automatic first-run setup.
|
||||
|
||||
## 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
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Library |
|
||||
|---|---|
|
||||
| Router | [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) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Open http://localhost:8080 and log in with **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/` |
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### MySQL
|
||||
|
||||
Edit `config.yaml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
type: mysql
|
||||
dsn: user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local
|
||||
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
|
||||
├── config/
|
||||
│ └── config.go # OS-aware config load / auto-create
|
||||
├── models/
|
||||
│ ├── user.go # User model + bcrypt
|
||||
│ └── db.go # GORM init, migration, seed
|
||||
├── middleware/
|
||||
│ └── auth.go # Auth guard, language detection
|
||||
├── 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)
|
||||
├── i18n/
|
||||
│ └── i18n.go # EN/ZH translation maps + Accept-Language
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # Header (nav + avatar dropdown) + footer
|
||||
│ ├── 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)
|
||||
├── etc/blog_go/
|
||||
│ └── config.yaml
|
||||
└── srv/blog_go/
|
||||
├── blog.db
|
||||
└── 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` | ✓ | ✗ |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Port string `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database-specific configuration.
|
||||
type DatabaseConfig struct {
|
||||
Type string `yaml:"type"` // "sqlite" (default) or "mysql"
|
||||
DSN string `yaml:"dsn"` // MySQL connection string (ignored for sqlite)
|
||||
}
|
||||
|
||||
// defaultPort is the fallback web server port.
|
||||
const defaultPort = "8080"
|
||||
|
||||
// getConfigPath returns the OS-aware config directory and config file path.
|
||||
func getConfigPath() (dir, file string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
dir = filepath.Join(".", "win", "etc", "blog_go")
|
||||
} else {
|
||||
dir = filepath.Join("/", "etc", "blog_go")
|
||||
}
|
||||
file = filepath.Join(dir, "config.yaml")
|
||||
return
|
||||
}
|
||||
|
||||
// getDefaultStoragePath returns the OS-aware default storage path.
|
||||
func getDefaultStoragePath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(".", "win", "srv", "blog_go")
|
||||
}
|
||||
return filepath.Join("/", "srv", "blog_go")
|
||||
}
|
||||
|
||||
// generateSecret returns a random-ish hex string for the session secret.
|
||||
func generateSecret() string {
|
||||
// Use a combination of hostname + random to produce a unique secret.
|
||||
hostname, _ := os.Hostname()
|
||||
input := fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// LoadConfig reads the config file or creates one with defaults.
|
||||
func LoadConfig() *Config {
|
||||
configDir, configFile := getConfigPath()
|
||||
defaultPath := getDefaultStoragePath()
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
// Check if config file exists; create with defaults if not.
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
log.Printf("Config file not found at %s, creating with defaults...", configFile)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create config directory %s: %v", configDir, err)
|
||||
}
|
||||
|
||||
cfg = &Config{
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
DSN: "",
|
||||
},
|
||||
Port: defaultPort,
|
||||
Path: defaultPath,
|
||||
Secret: generateSecret(),
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal default config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, data, 0644); err != nil {
|
||||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||||
}
|
||||
log.Printf("Default config created at %s", configFile)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Read existing config file.
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not read config file %s: %v, using defaults", configFile, err)
|
||||
return applyDefaults(cfg, defaultPath)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
|
||||
cfg = &Config{}
|
||||
}
|
||||
|
||||
return applyDefaults(cfg, defaultPath)
|
||||
}
|
||||
|
||||
// applyDefaults fills zero-value fields with sensible defaults.
|
||||
func applyDefaults(cfg *Config, defaultPath string) *Config {
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = defaultPort
|
||||
}
|
||||
if cfg.Database.Type == "" {
|
||||
cfg.Database.Type = "sqlite"
|
||||
}
|
||||
if cfg.Path == "" {
|
||||
cfg.Path = defaultPath
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
cfg.Secret = generateSecret()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
module go_blog
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminDashboard renders the protected admin dashboard.
|
||||
func AdminDashboard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
username, _ := c.Get("username")
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["dash_page_title"]
|
||||
data["Username"] = username
|
||||
c.HTML(http.StatusOK, "dashboard", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// LoginPage renders the login form.
|
||||
func LoginPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["page_login"]
|
||||
if c.Query("error") == "1" {
|
||||
data["Error"] = tr["login_error"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "login", data)
|
||||
}
|
||||
}
|
||||
|
||||
// Login processes the login form submission.
|
||||
func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.CheckPassword(password) {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session.
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", user.ID)
|
||||
session.Set("username", user.Username)
|
||||
if err := session.Save(); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to save session")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
}
|
||||
}
|
||||
|
||||
// Logout clears the session and redirects home.
|
||||
func Logout() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultData builds a base gin.H map populated with values set by the
|
||||
// SetUserContext middleware (translations, language, auth state). Handlers
|
||||
// add page-specific fields on top and pass it to c.HTML().
|
||||
func DefaultData(c *gin.Context) gin.H {
|
||||
tr, _ := c.Get("tr")
|
||||
isLoggedIn, _ := c.Get("is_logged_in")
|
||||
username, _ := c.Get("username")
|
||||
lang, _ := c.Get("lang")
|
||||
switchLang, _ := c.Get("switch_lang")
|
||||
avatar, _ := c.Get("avatar")
|
||||
displayName, _ := c.Get("display_name")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
return gin.H{
|
||||
"Tr": tr,
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"Username": username,
|
||||
"Lang": lang,
|
||||
"SwitchLang": switchLang,
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
}
|
||||
}
|
||||
|
||||
// getTr is a convenience helper that returns the translation map from the
|
||||
// Gin context, falling back to an empty map if not set.
|
||||
func getTr(c *gin.Context) map[string]string {
|
||||
tr, ok := c.Get("tr")
|
||||
if !ok {
|
||||
return map[string]string{}
|
||||
}
|
||||
m, ok := tr.(map[string]string)
|
||||
if !ok {
|
||||
return map[string]string{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HomePage renders the public home page.
|
||||
func HomePage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["page_home"]
|
||||
c.HTML(http.StatusOK, "home", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// ProfilePage renders the profile edit page.
|
||||
func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["profile_title"]
|
||||
data["Profile"] = user
|
||||
|
||||
// Flash messages (success / error).
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["profile_saved"]
|
||||
}
|
||||
if msg := c.Query("error"); msg == "pw" {
|
||||
data["Error"] = tr["profile_wrong_password"]
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "profile", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProfile processes the profile edit form (multipart).
|
||||
func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// --- Text fields ---
|
||||
if v := c.PostForm("display_name"); v != "" {
|
||||
user.DisplayName = v
|
||||
}
|
||||
if v := c.PostForm("gender"); v != "" {
|
||||
user.Gender = v
|
||||
}
|
||||
if v := c.PostForm("email"); v != "" {
|
||||
user.Email = v
|
||||
}
|
||||
if v := c.PostForm("birthday"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
user.Birthday = &t
|
||||
}
|
||||
}
|
||||
|
||||
// --- Avatar upload ---
|
||||
file, header, err := c.Request.FormFile("avatar")
|
||||
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"
|
||||
}
|
||||
|
||||
// Save under storagePath/avatars/.
|
||||
avatarDir := filepath.Join(storagePath, "avatars")
|
||||
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Use user ID as filename base to avoid duplicates.
|
||||
savedName := fmt.Sprintf("%d%s", user.ID, ext)
|
||||
savedPath := filepath.Join(avatarDir, savedName)
|
||||
|
||||
dst, err := os.Create(savedPath)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
user.Avatar = savedName
|
||||
// Update session display immediately.
|
||||
session.Set("avatar", savedName)
|
||||
}
|
||||
|
||||
// --- Password change ---
|
||||
currentPass := c.PostForm("current_password")
|
||||
newPass := c.PostForm("new_password")
|
||||
if currentPass != "" && newPass != "" {
|
||||
if !user.CheckPassword(currentPass) {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile?error=pw")
|
||||
return
|
||||
}
|
||||
if err := user.SetPassword(newPass); err != nil {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save user record.
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Update display name in session.
|
||||
session.Set("display_name", user.DisplayName)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/profile?saved=1")
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Lang represents a supported language code.
|
||||
type Lang string
|
||||
|
||||
const (
|
||||
EN Lang = "en"
|
||||
ZH Lang = "zh"
|
||||
)
|
||||
|
||||
// translations holds all UI strings keyed by language then translation key.
|
||||
var translations = map[Lang]map[string]string{
|
||||
EN: {
|
||||
// Nav
|
||||
"site_title": "Go Blog",
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Logout",
|
||||
|
||||
// Home page
|
||||
"home_welcome": "Welcome to Go Blog",
|
||||
"home_subtitle": "A simple, fast blog engine built with Go, Gin, and Tailwind CSS.",
|
||||
"home_sign_in": "Sign In",
|
||||
"home_to_dash": "Dashboard",
|
||||
"home_latest": "Latest Posts",
|
||||
"home_post1_tit": "Getting Started",
|
||||
"home_post1_dsc": "Welcome to your new blog! This is a placeholder post. Start writing to see your content here.",
|
||||
"home_post2_tit": "Hello World",
|
||||
"home_post2_dsc": "Blog posts will appear here once you start publishing from the admin dashboard.",
|
||||
"home_post3_tit": "Built with Go",
|
||||
"home_post3_dsc": "This blog engine uses Go, Gin web framework, GORM, and Tailwind CSS for a modern experience.",
|
||||
|
||||
// Login page
|
||||
"login_title": "Sign In",
|
||||
"login_username": "Username",
|
||||
"login_password": "Password",
|
||||
"login_ph_user": "Enter your username",
|
||||
"login_ph_pass": "Enter your password",
|
||||
"login_submit": "Sign In",
|
||||
"login_error": "Invalid username or password.",
|
||||
|
||||
// Dashboard
|
||||
"dash_title": "Dashboard",
|
||||
"dash_welcome": "Welcome back,",
|
||||
"dash_posts": "Posts",
|
||||
"dash_pages": "Pages",
|
||||
"dash_users": "Users",
|
||||
"dash_mgmt_title": "Blog Management",
|
||||
"dash_mgmt_desc": "Post management, categories, and settings will appear here in future updates.",
|
||||
"dash_page_title": "Dashboard",
|
||||
|
||||
// Footer
|
||||
"footer_text": "© 2026 Go Blog. Powered by Go & Gin.",
|
||||
|
||||
// Page titles
|
||||
"page_home": "Home",
|
||||
"page_login": "Login",
|
||||
|
||||
// Language switcher
|
||||
"lang_switch": "中文",
|
||||
|
||||
// Profile dropdown & page
|
||||
"profile": "Profile",
|
||||
"admin_panel": "Admin Panel",
|
||||
"profile_title": "Edit Profile",
|
||||
"profile_avatar": "Avatar",
|
||||
"profile_change_avatar": "Change Avatar",
|
||||
"profile_display_name": "Display Name",
|
||||
"profile_gender": "Gender",
|
||||
"profile_birthday": "Birthday",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Change Password",
|
||||
"profile_current_password": "Current Password",
|
||||
"profile_new_password": "New Password",
|
||||
"profile_save": "Save Changes",
|
||||
"profile_saved": "Profile updated.",
|
||||
"profile_wrong_password": "Current password is incorrect.",
|
||||
"gender_male": "Male",
|
||||
"gender_female": "Female",
|
||||
"gender_other": "Other",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
"site_title": "Go 博客",
|
||||
"home": "首页",
|
||||
"login": "登录",
|
||||
"dashboard": "后台",
|
||||
"logout": "退出",
|
||||
|
||||
// 首页
|
||||
"home_welcome": "欢迎来到 Go Blog",
|
||||
"home_subtitle": "一个使用 Go、Gin 和 Tailwind CSS 构建的简洁快速的博客引擎。",
|
||||
"home_sign_in": "登录",
|
||||
"home_to_dash": "后台管理",
|
||||
"home_latest": "最新文章",
|
||||
"home_post1_tit": "入门指南",
|
||||
"home_post1_dsc": "欢迎来到你的新博客!这是一篇占位文章。开始写作后,你的内容将显示在这里。",
|
||||
"home_post2_tit": "你好,世界",
|
||||
"home_post2_dsc": "当你从后台管理面板发布文章后,博客文章将显示在这里。",
|
||||
"home_post3_tit": "使用 Go 构建",
|
||||
"home_post3_dsc": "此博客引擎使用 Go、Gin Web 框架、GORM 和 Tailwind CSS 构建,提供现代化的体验。",
|
||||
|
||||
// 登录页
|
||||
"login_title": "登录",
|
||||
"login_username": "用户名",
|
||||
"login_password": "密码",
|
||||
"login_ph_user": "请输入用户名",
|
||||
"login_ph_pass": "请输入密码",
|
||||
"login_submit": "登录",
|
||||
"login_error": "用户名或密码错误。",
|
||||
|
||||
// 后台
|
||||
"dash_title": "后台管理",
|
||||
"dash_welcome": "欢迎回来,",
|
||||
"dash_posts": "文章",
|
||||
"dash_pages": "页面",
|
||||
"dash_users": "用户",
|
||||
"dash_mgmt_title": "博客管理",
|
||||
"dash_mgmt_desc": "文章管理、分类和设置将在后续版本中添加。",
|
||||
"dash_page_title": "后台管理",
|
||||
|
||||
// 页脚
|
||||
"footer_text": "© 2026 Go Blog。由 Go & Gin 驱动。",
|
||||
|
||||
// 页面标题
|
||||
"page_home": "首页",
|
||||
"page_login": "登录",
|
||||
|
||||
// 语言切换
|
||||
"lang_switch": "English",
|
||||
|
||||
// 个人中心下拉菜单 & 页面
|
||||
"profile": "个人信息",
|
||||
"admin_panel": "后台管理",
|
||||
"profile_title": "编辑个人信息",
|
||||
"profile_avatar": "头像",
|
||||
"profile_change_avatar": "更换头像",
|
||||
"profile_display_name": "显示名称",
|
||||
"profile_gender": "性别",
|
||||
"profile_birthday": "生日",
|
||||
"profile_email": "邮箱",
|
||||
"profile_change_password": "修改密码",
|
||||
"profile_current_password": "当前密码",
|
||||
"profile_new_password": "新密码",
|
||||
"profile_save": "保存修改",
|
||||
"profile_saved": "个人信息已更新。",
|
||||
"profile_wrong_password": "当前密码错误。",
|
||||
"gender_male": "男",
|
||||
"gender_female": "女",
|
||||
"gender_other": "其他",
|
||||
},
|
||||
}
|
||||
|
||||
// T returns a copy of the translation map for the given language.
|
||||
// Falls back to English if the language is not supported.
|
||||
func T(l Lang) map[string]string {
|
||||
t, ok := translations[l]
|
||||
if !ok {
|
||||
t = translations[EN]
|
||||
}
|
||||
// Return a shallow copy so callers cannot mutate the original map values.
|
||||
out := make(map[string]string, len(t))
|
||||
for k, v := range t {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DetectLang parses the Accept-Language header and returns the best matching
|
||||
// supported language. Returns EN for unsupported languages.
|
||||
func DetectLang(acceptHeader string) Lang {
|
||||
if acceptHeader == "" {
|
||||
return EN
|
||||
}
|
||||
|
||||
// Accept-Language format: "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7"
|
||||
// Split by comma, then extract primary language from each entry.
|
||||
entries := strings.Split(acceptHeader, ",")
|
||||
for _, entry := range entries {
|
||||
// Trim spaces and remove quality values.
|
||||
entry = strings.TrimSpace(entry)
|
||||
if idx := strings.Index(entry, ";"); idx != -1 {
|
||||
entry = entry[:idx]
|
||||
}
|
||||
// Extract primary language (before any "-" subtag).
|
||||
primary := strings.ToLower(entry)
|
||||
if idx := strings.Index(primary, "-"); idx != -1 {
|
||||
primary = primary[:idx]
|
||||
}
|
||||
switch primary {
|
||||
case "zh":
|
||||
return ZH
|
||||
case "en":
|
||||
return EN
|
||||
}
|
||||
}
|
||||
|
||||
return EN
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/config"
|
||||
"go_blog/handlers"
|
||||
"go_blog/middleware"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Load configuration (auto-creates if missing).
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// 2. Initialize the database (auto-migrates, seeds admin).
|
||||
db := models.InitDB(cfg)
|
||||
|
||||
// 3. Create session store (cookie-based).
|
||||
store := cookie.NewStore([]byte(cfg.Secret))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400, // 24 hours
|
||||
HttpOnly: true, // prevent XSS access
|
||||
Secure: false, // set true in production with HTTPS
|
||||
})
|
||||
|
||||
// 4. Create Gin router.
|
||||
router := gin.Default()
|
||||
|
||||
// 5. Load HTML templates.
|
||||
router.LoadHTMLGlob("templates/**/*.html")
|
||||
|
||||
// 6. Serve uploaded files (avatars etc.) from the storage path.
|
||||
router.Static("/uploads", cfg.Path)
|
||||
|
||||
// 6. Global session middleware.
|
||||
router.Use(sessions.Sessions("blog_session", store))
|
||||
|
||||
// 7. Global context middleware (sets IsLoggedIn, Username for templates).
|
||||
router.Use(middleware.SetUserContext(db))
|
||||
|
||||
// 8. Register routes.
|
||||
router.GET("/", handlers.HomePage())
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
|
||||
// Protected admin routes.
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard())
|
||||
}
|
||||
|
||||
// Protected profile routes.
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired())
|
||||
{
|
||||
profile.GET("", handlers.ProfilePage(db))
|
||||
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
}
|
||||
|
||||
// 9. Start the server.
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
log.Printf("Go Blog starting on http://localhost%s", addr)
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/i18n"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// AuthRequired is middleware that protects routes. If the user is not logged in,
|
||||
// they are redirected to /login.
|
||||
func AuthRequired() 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
|
||||
}
|
||||
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 {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// --- Language detection ---
|
||||
// Priority: query param > session > Accept-Language header > default EN
|
||||
var lang i18n.Lang
|
||||
queryLang := c.Query("lang")
|
||||
|
||||
switch queryLang {
|
||||
case "zh":
|
||||
lang = i18n.ZH
|
||||
case "en":
|
||||
lang = i18n.EN
|
||||
case "":
|
||||
// Try session
|
||||
if saved, ok := session.Get("lang").(string); ok {
|
||||
lang = i18n.Lang(saved)
|
||||
}
|
||||
if lang == "" {
|
||||
// Try Accept-Language header
|
||||
lang = i18n.DetectLang(c.GetHeader("Accept-Language"))
|
||||
}
|
||||
default:
|
||||
// Unsupported language in query — fall back to English.
|
||||
lang = i18n.EN
|
||||
}
|
||||
|
||||
// Persist language in session.
|
||||
session.Set("lang", string(lang))
|
||||
session.Save()
|
||||
|
||||
// Make translations available in the Gin context.
|
||||
c.Set("tr", i18n.T(lang))
|
||||
c.Set("lang", string(lang))
|
||||
|
||||
// Set the opposite language code for the language switcher link.
|
||||
switchLang := "zh"
|
||||
if lang == i18n.ZH {
|
||||
switchLang = "en"
|
||||
}
|
||||
c.Set("switch_lang", switchLang)
|
||||
|
||||
// --- Auth state ---
|
||||
userID := session.Get("user_id")
|
||||
isLoggedIn := false
|
||||
var username string
|
||||
var avatar string
|
||||
var displayName string
|
||||
var role string
|
||||
|
||||
if userID != nil {
|
||||
isLoggedIn = true
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err == nil {
|
||||
username = user.Username
|
||||
avatar = user.Avatar
|
||||
displayName = user.DisplayName
|
||||
role = user.Role
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("is_logged_in", isLoggedIn)
|
||||
c.Set("username", username)
|
||||
c.Set("avatar", avatar)
|
||||
c.Set("display_name", displayName)
|
||||
c.Set("role", role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go_blog/config"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
// DB is the global database connection, initialized by InitDB.
|
||||
var DB *gorm.DB
|
||||
|
||||
// InitDB opens the database connection, runs migrations, and seeds the admin user.
|
||||
func InitDB(cfg *config.Config) *gorm.DB {
|
||||
// Ensure the storage path exists.
|
||||
if err := os.MkdirAll(cfg.Path, 0755); err != nil {
|
||||
log.Fatalf("Failed to create storage directory %s: %v", cfg.Path, err)
|
||||
}
|
||||
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cfg.Database.Type {
|
||||
case "mysql":
|
||||
dsn := cfg.Database.DSN
|
||||
if dsn == "" {
|
||||
dsn = "root:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
}
|
||||
dialector = mysql.Open(dsn)
|
||||
default:
|
||||
dbPath := filepath.Join(cfg.Path, "blog.db")
|
||||
dialector = sqlite.Open(dbPath)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Auto-migrate the User table (idempotent).
|
||||
if err := db.AutoMigrate(&User{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
if count == 0 {
|
||||
admin := &User{
|
||||
Username: "admin",
|
||||
DisplayName: "Administrator",
|
||||
Gender: "other",
|
||||
Status: StatusNormal,
|
||||
Role: RoleAdmin,
|
||||
}
|
||||
if err := admin.SetPassword("admin"); err != nil {
|
||||
log.Fatalf("Failed to hash admin password: %v", err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
log.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
log.Println("==============================================")
|
||||
log.Println(" First run: created default admin user.")
|
||||
log.Println(" Username: admin")
|
||||
log.Println(" Password: admin")
|
||||
log.Println(" Please change this password immediately!")
|
||||
log.Println("==============================================")
|
||||
}
|
||||
|
||||
// Migration fix: always set admin role on the admin user.
|
||||
result := db.Model(&User{}).Where("username = ?", "admin").Update("role", RoleAdmin)
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migration: set admin role for existing admin user (rows affected: %d)", result.RowsAffected)
|
||||
}
|
||||
|
||||
DB = db
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Account status constants.
|
||||
const (
|
||||
StatusDisabled = 0 // 禁用
|
||||
StatusNormal = 1 // 正常
|
||||
StatusLocked = 2 // 锁定
|
||||
StatusUnactivated = 3 // 未激活
|
||||
)
|
||||
|
||||
// Role constants.
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleAuthor = "author"
|
||||
)
|
||||
|
||||
// User represents a blog user (author / admin).
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null;size:255" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
DisplayName string `gorm:"size:255" json:"display_name"`
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Birthday *time.Time `json:"birthday"`
|
||||
Gender string `gorm:"size:16" json:"gender"`
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
Status int `gorm:"default:1" json:"status"`
|
||||
Role string `gorm:"size:32;default:author" json:"role"`
|
||||
}
|
||||
|
||||
// SetPassword hashes the plain-text password with bcrypt and stores it.
|
||||
func (u *User) SetPassword(plain string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword compares a plain-text password against the stored bcrypt hash.
|
||||
func (u *User) CheckPassword(plain string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "dashboard"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "dash_title"}}</h2>
|
||||
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
|
||||
</div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer"
|
||||
>
|
||||
{{index .Tr "logout"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_posts"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_pages"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_users"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">1</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for future features -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">{{index .Tr "dash_mgmt_title"}}</h3>
|
||||
<p class="text-gray-500">{{index .Tr "dash_mgmt_desc"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "header"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<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"}}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "home"}}</a>
|
||||
{{if .IsLoggedIn}}
|
||||
<!-- Avatar Dropdown (click to toggle) -->
|
||||
<div class="relative" id="avatarDropdown">
|
||||
<button type="button" id="avatarBtn" class="w-9 h-9 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm cursor-pointer" onclick="toggleDropdown()">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
</button>
|
||||
<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>
|
||||
{{if eq .Role "admin"}}
|
||||
<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>
|
||||
{{end}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
|
||||
{{index .Tr "logout"}}
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
{{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"}}
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
function toggleDropdown() {
|
||||
var menu = document.getElementById('dropdownMenu');
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var dropdown = document.getElementById('avatarDropdown');
|
||||
var menu = document.getElementById('dropdownMenu');
|
||||
if (dropdown && !dropdown.contains(e.target)) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-5xl mx-auto px-4 py-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post1_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post1_dsc"}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post2_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post2_dsc"}}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post3_tit"}}</h3>
|
||||
<p class="text-gray-500 text-sm">{{index .Tr "home_post3_dsc"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "login"}}
|
||||
{{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 "login_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="/login" method="post" class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
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 "login_ph_user"}}"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_password"}}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
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 "login_ph_pass"}}"
|
||||
>
|
||||
</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 "login_submit"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,99 @@
|
||||
{{define "profile"}}
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="max-w-2xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "profile_title"}}</h2>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{{.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 text-sm">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
|
||||
<!-- Avatar Section -->
|
||||
<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 "profile_avatar"}}</h3>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 flex items-center justify-center bg-gray-200">
|
||||
{{if .Profile.Avatar}}
|
||||
<img src="/uploads/avatars/{{.Profile.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-10 h-10 text-gray-500" 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>
|
||||
<label class="cursor-pointer bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm">
|
||||
{{index .Tr "profile_change_avatar"}}
|
||||
<input type="file" name="avatar" accept="image/*" class="hidden" onchange="this.form.submit()">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_title"}}</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_display_name"}}</label>
|
||||
<input type="text" name="display_name" value="{{.Profile.DisplayName}}"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_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="male" {{if eq .Profile.Gender "male"}}selected{{end}}>{{index .Tr "gender_male"}}</option>
|
||||
<option value="female" {{if eq .Profile.Gender "female"}}selected{{end}}>{{index .Tr "gender_female"}}</option>
|
||||
<option value="other" {{if eq .Profile.Gender "other"}}selected{{end}}>{{index .Tr "gender_other"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_birthday"}}</label>
|
||||
<input type="date" name="birthday"
|
||||
value="{{if .Profile.Birthday}}{{.Profile.Birthday.Format "2006-01-02"}}{{end}}"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_email"}}</label>
|
||||
<input type="email" name="email" value="{{.Profile.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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_change_password"}}</h3>
|
||||
<p class="text-sm text-gray-500">Leave blank if you don't want to change your password.</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_current_password"}}</label>
|
||||
<input type="password" name="current_password"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_new_password"}}</label>
|
||||
<input type="password" name="new_password"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "profile_save"}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user