first commit, mate!
CI/CD Pipeline / Test & Lint (push) Waiting to run
CI/CD Pipeline / Build Docker Image (push) Blocked by required conditions
CI/CD Pipeline / Security Scan (push) Waiting to run

This commit is contained in:
2026-08-08 14:43:59 +08:00
commit 852613586f
19 changed files with 3455 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Database Configuration
DB_HOST=mariadb
DB_PORT=3306
DB_USER=cms_user
DB_PASSWORD=change_me_secure_password
DB_ROOT_PASSWORD=root_secure_password
DB_NAME=blogging_cms
# Application Configuration
PORT=8080
SITE_URL=http://localhost:8080
SESSION_KEY=change_me_to_random_secure_key
# Optional: Markdown rendering options
MARKDOWN_BREAKS=true
MARKDOWN_TYPOGRAPHER=false
# Optional: Email configuration (for future features)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
+131
View File
@@ -0,0 +1,131 @@
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11.0-alpine
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: blogging_cms
MYSQL_USER: cms_user
MYSQL_PASSWORD: cms_password
options: >-
--health-cmd="healthcheck.sh --connect"
--health-interval=10s
--health-timeout=5s
--health-retries=3
ports:
- 3306:3306
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Cache Go modules
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Format check
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "Code formatting issues found:"
gofmt -s -d .
exit 1
fi
- name: Vet
run: go vet ./...
- name: Run tests
env:
DB_HOST: localhost
DB_PORT: 3306
DB_USER: cms_user
DB_PASSWORD: cms_password
DB_NAME: blogging_cms
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
+61
View File
@@ -0,0 +1,61 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.so.*
*.dylib
blogging-cms
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# Dependency directories
vendor/
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Temporary files
tmp/
temp/
*.tmp
# Build output
dist/
build/
# Docker
.dockerignore
docker-compose.override.yml
# Credentials
private_key.pem
public_key.pem
*.key
*.pem
+148
View File
@@ -0,0 +1,148 @@
# Contributing to Blogging CMS
First, thank you for considering contributing! This project is maintained by one person, and community contributions are essential.
## Code of Conduct
- Be respectful and inclusive
- Provide constructive feedback
- Focus on the code, not the person
- Help others learn and grow
## Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally**
```bash
git clone https://github.com/your-username/blogging-cms.git
cd blogging-cms
```
3. **Create a feature branch**
```bash
git checkout -b feature/your-feature-name
```
4. **Set up development environment**
```bash
make setup
make dev
```
## Development Guidelines
### Code Style
- Follow Go conventions (use `gofmt`)
- Keep functions small and focused
- Write meaningful variable and function names
- Add comments for exported functions
### Before Submitting
1. **Format your code**
```bash
make fmt
```
2. **Run linter**
```bash
make lint
make vet
```
3. **Run tests**
```bash
make test
```
4. **Test in Docker**
```bash
make docker-rebuild
```
## Pull Request Process
1. Update documentation if needed (README.md, comments)
2. Add tests for new features
3. Ensure all tests pass locally
4. Write a clear commit message
5. Push to your fork
6. Submit a Pull Request with a clear description
### Commit Messages
Use clear, descriptive commit messages:
- ✨ `feat:` for new features
- 🐛 `fix:` for bug fixes
- 📝 `docs:` for documentation
- ♻️ `refactor:` for refactoring
- ✅ `test:` for adding tests
- 🎨 `style:` for formatting
Example:
```
feat: add comment moderation panel
- Add admin interface for approving/rejecting comments
- Send notification email to post author
- Add tests for moderation logic
Fixes #123
```
## Areas for Contribution
### High Priority
- [ ] Password hashing with bcrypt
- [ ] Comment threading
- [ ] Batch operations in dashboard
- [ ] Email notifications
- [ ] Advanced search filters
### Medium Priority
- [ ] User profile pages
- [ ] Post scheduling
- [ ] Image optimization
- [ ] SEO metadata
- [ ] Social media preview cards
### Lower Priority
- [ ] Dark mode theme
- [ ] Multilingual support
- [ ] Export functionality
- [ ] Analytics integration
## Bug Reports
Found a bug? Open an issue with:
- Clear title and description
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Go version, Docker version)
- Screenshots if applicable
## Feature Requests
Have an idea? Open an issue with:
- Clear description of the feature
- Why it would be useful
- Possible implementation approach
- Any relevant examples or links
## Documentation
- Keep README.md updated
- Add inline code comments for complex logic
- Document API changes
- Update architecture in README if needed
## Questions?
- Check existing issues/discussions first
- Ask in GitHub Discussions
- Email: kalvin@obulou.org
---
Thank you for contributing! Every contribution makes this project better. 🙏
+48
View File
@@ -0,0 +1,48 @@
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git ca-certificates
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o blogging-cms .
# Runtime stage
FROM alpine:latest
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
# Copy built application
COPY --from=builder /app/blogging-cms .
# Copy templates and static files
COPY templates ./templates
COPY static ./static
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/ || exit 1
CMD ["./blogging-cms"]
+293
View File
@@ -0,0 +1,293 @@
# Blogging CMS - Coolify Installation Guide
Deploy your self-hosted blogging platform on Coolify in minutes.
## Prerequisites
- Active Coolify instance (v4.0+)
- Git repository (GitHub, GitLab, Gitea)
- Domain name (optional, but recommended)
- 1GB RAM minimum
## Step 1: Prepare Your Repository
1. **Fork or clone this repository to your Git provider**
```bash
git clone https://github.com/kalvin0x8d0/blogging-cms.git
cd blogging-cms
```
2. **Push to your Git provider** (if using your own fork)
```bash
git remote add origin <your-git-repo-url>
git push -u origin main
```
## Step 2: Create Coolify Project
1. **Log into your Coolify dashboard**
2. **Click "New Project"**
3. **Select "Docker Compose"** as the project type
4. **Name your project** (e.g., "My Blog")
## Step 3: Connect Your Git Repository
1. **Click "New Service"**
2. **Select "Git Repository"**
3. **Choose your Git provider** (GitHub/GitLab/Gitea)
4. **Select this repository**
5. **Leave the path as `.`** (root directory)
6. **Click "Save"**
## Step 4: Configure Environment Variables
In Coolify's environment editor, add:
```env
# Database
DB_HOST=mariadb
DB_PORT=3306
DB_USER=cms_user
DB_PASSWORD=generate_secure_password_here
DB_ROOT_PASSWORD=generate_root_password_here
DB_NAME=blogging_cms
# Application
PORT=8080
SITE_URL=https://yourdomain.com
SESSION_KEY=generate_secure_random_key_here
# Optional
MARKDOWN_BREAKS=true
MARKDOWN_TYPOGRAPHER=false
```
### Generating Secure Keys
```bash
# Generate SESSION_KEY (on your local machine)
openssl rand -base64 32
# Or use this Python one-liner
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```
## Step 5: Configure Reverse Proxy (Caddy)
1. **In Coolify, go to "Network" or "Proxy" settings**
2. **Click "New Reverse Proxy"**
3. **Set Domain**: `yourdomain.com`
4. **Set Target**: `http://localhost:8080` (or container name)
5. **Enable HTTPS**: Yes (auto-certificate with Let's Encrypt)
6. **Save**
## Step 6: Deploy
1. **Click "Deploy"** in your project
2. **Monitor logs**:
```
docker-compose logs -f
```
3. **Wait for MariaDB to initialize** (30-60 seconds)
## Step 7: Access Your Blog
- **Homepage**: `https://yourdomain.com`
- **Dashboard**: `https://yourdomain.com/dashboard`
- **RSS Feed**: `https://yourdomain.com/feed`
## Post-Deployment
### 1. First-Time Setup
```bash
# Access Coolify terminal
docker-compose exec app sh
# Check database connection
go run main.go
```
### 2. Create Admin User
```bash
# Via API (POST request)
curl -X POST https://yourdomain.com/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"email": "you@example.com",
"password": "secure_password"
}'
```
### 3. Backup Strategy
**Enable automated backups in Coolify:**
1. **Storage Settings** → **Add Volume Backup**
2. **Select**: `mariadb_data`
3. **Frequency**: Daily or Weekly
4. **Retention**: 30 days
### 4. Domain Configuration
If using DNS, add:
```dns
blog.example.com CNAME your-coolify-domain.com
```
## Troubleshooting
### Container Won't Start
**Check logs:**
```bash
docker-compose logs app
docker-compose logs mariadb
```
**Common issues:**
- Port 8080 already in use → Change PORT in environment
- MariaDB not ready → Wait 1-2 minutes, redeploy
- Database password error → Verify all DB_* variables match
### Posts Not Showing
1. Verify database is running:
```bash
docker-compose exec mariadb mysql -u cms_user -p blogging_cms -e "SHOW TABLES;"
```
2. Check if posts are published:
```bash
SELECT title, published FROM posts LIMIT 5;
```
### Static Files Return 404
- Ensure `/static` directory exists
- Check file permissions in Docker: `ls -la static/`
- Verify paths in `main.go` match your setup
### HTTPS Certificate Issues
1. **Let's Encrypt renewal**:
```bash
docker-compose down
docker-compose up -d
```
2. **Manual certificate**:
- Use Coolify's built-in cert manager
- Or configure in reverse proxy settings
## Monitoring & Maintenance
### Health Checks
Coolify monitors the `/` endpoint. If unhealthy:
```bash
curl https://yourdomain.com/
```
### Database Maintenance
**Weekly optimization:**
```bash
docker-compose exec mariadb mysql -u cms_user -p blogging_cms \
-e "OPTIMIZE TABLE posts, users, comments;"
```
### Log Rotation
Coolify handles log rotation automatically. Check:
- Application logs: Docker logs
- Access logs: Reverse proxy logs
## Performance Optimization
### For Coolify
1. **Enable Docker layer caching**
- Coolify → Project Settings → Docker
2. **Set appropriate resource limits**
- mariadb: 256MB RAM
- app: 128MB RAM
3. **Enable persistent volumes**
- Database data
- Static assets cache
### Application Settings
Add to environment:
```env
# Connection pooling
DB_MAX_CONNECTIONS=20
# Cache headers
CACHE_POSTS=3600
```
## Updating the Application
1. **Pull latest changes**:
```bash
git pull origin main
git push origin main
```
2. **Coolify auto-redeployment** (if enabled)
- Or manually trigger deploy
3. **Database migrations**:
- Usually automatic on startup
- Check logs for any errors
## Backup & Restore
### Automatic Backups
Coolify has built-in volume backup:
1. Storage → Volumes → mariadb_data
2. Set backup schedule
3. Retention policy: 30 days
### Manual Backup
```bash
docker-compose exec mariadb mysqldump \
-u cms_user -p blogging_cms > backup.sql
```
### Restore
```bash
docker-compose exec mariadb mysql \
-u cms_user -p blogging_cms < backup.sql
```
## Security Checklist
- ✅ Change all default passwords
- ✅ Enable HTTPS via Let's Encrypt
- ✅ Set strong SESSION_KEY
- ✅ Enable database backups
- ✅ Configure firewall rules
- ✅ Monitor access logs
- ✅ Keep Coolify updated
## Getting Help
- **Issues**: GitHub Issues (if public repo)
- **Coolify Docs**: docs.coollabs.io
- **Docker Help**: docs.docker.com
---
**Your self-hosted blog is ready!** 🎉
Start writing at `https://yourdomain.com/dashboard` ✍️
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kalvin (kalvin0x8d0)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84
View File
@@ -0,0 +1,84 @@
.PHONY: help build run test clean docker-build docker-up docker-down fmt lint
# Variables
APP_NAME=blogging-cms
GO_VERSION=1.21
PORT=8080
help: ## Display this help screen
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
install-deps: ## Install Go dependencies
go mod download
go mod tidy
build: ## Build the application
CGO_ENABLED=0 GOOS=linux go build -o $(APP_NAME) .
run: ## Run the application locally
go run main.go
dev: install-deps ## Run in development mode with hot reload
which air > /dev/null || go install github.com/cosmtrek/air@latest
air
test: ## Run tests
go test -v -race -coverprofile=coverage.out ./...
test-coverage: test ## Run tests and display coverage
go tool cover -html=coverage.out
clean: ## Clean build artifacts
rm -f $(APP_NAME)
rm -f coverage.out
rm -rf dist/
fmt: ## Format code
gofmt -s -w .
go mod tidy
lint: ## Run linter
which golangci-lint > /dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...
vet: ## Run go vet
go vet ./...
docker-build: ## Build Docker image
docker build -t $(APP_NAME):latest .
docker-up: ## Start Docker containers
docker-compose up -d
docker-down: ## Stop Docker containers
docker-compose down
docker-logs: ## View Docker logs
docker-compose logs -f app
docker-clean: ## Remove Docker containers and volumes
docker-compose down -v
docker-rebuild: docker-down docker-build docker-up ## Rebuild and restart containers
db-shell: ## Access MariaDB shell
docker-compose exec mariadb mysql -u cms_user -p blogging_cms
db-reset: ## Reset database (DANGEROUS!)
docker-compose exec mariadb mysql -u root -p -e "DROP DATABASE blogging_cms; CREATE DATABASE blogging_cms;"
setup: ## Complete setup for development
cp .env.example .env
go mod download
docker-compose up -d
@echo "Setup complete! Run 'make dev' to start developing."
update-deps: ## Update Go dependencies
go get -u ./...
go mod tidy
check: fmt vet lint ## Run all checks
all: clean install-deps build test ## Build and test
.DEFAULT_GOAL := help
+358
View File
@@ -0,0 +1,358 @@
# Blogging CMS
A lightweight, self-hosted blogging platform built with Go, HTML/CSS/JS, and MariaDB. Designed for digital sovereignty and easy Coolify deployment.
## Features
- ✍️ **Markdown Support** Write posts in Markdown with live preview
- 📝 **Two Post Types** Full blog posts or quick micro-posts
- 🏷️ **Tagging & Categories** Organize content flexibly
- 🔍 **Full-Text Search** Quick post discovery
- 📡 **RSS Feed** Auto-generated feed for subscribers
- 👤 **User Management** Multiple authors with role-based access
- 💬 **Comments** Moderated comment system
- 🎨 **Material Design 3** Modern, responsive UI
- 🔒 **Session-Based Auth** Secure user authentication
- 📱 **Mobile Responsive** Works on all devices
- 🐳 **Docker Ready** One-command deployment on Coolify
## Architecture
```
blogging-cms/
├── main.go # Go backend with HTTP handlers & DB
├── go.mod / go.sum # Go dependencies
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Full stack (app + MariaDB)
├── .env.example # Configuration template
├── README.md # This file
├── .gitignore # Git configuration
├── templates/ # HTML templates
│ ├── home.html # Homepage
│ ├── post.html # Single post view
│ ├── category.html # Category listing
│ └── dashboard.html # Admin dashboard
├── static/ # Frontend assets
│ ├── css/
│ │ └── material.css # Material Design 3 stylesheet
│ └── js/
│ └── app.js # Frontend logic & API client
└── .github/
└── workflows/ # CI/CD (optional)
```
## Quick Start
### Prerequisites
- Docker & Docker Compose
- Or: Go 1.21+, MariaDB 11.0+
### With Docker (Recommended)
1. Clone the repository:
```bash
git clone https://github.com/kalvin0x8d0/blogging-cms.git
cd blogging-cms
```
2. Configure environment:
```bash
cp .env.example .env
# Edit .env with your settings
nano .env
```
3. Start the stack:
```bash
docker-compose up -d
```
4. Access the application:
- Homepage: `http://localhost:8080`
- Dashboard: `http://localhost:8080/dashboard`
### Without Docker
1. Install dependencies:
```bash
go mod download
```
2. Configure MariaDB:
```bash
# Create database and user
mysql -u root -p
CREATE DATABASE blogging_cms;
CREATE USER 'cms_user'@'localhost' IDENTIFIED BY 'secure_password';
GRANT ALL PRIVILEGES ON blogging_cms.* TO 'cms_user'@'localhost';
FLUSH PRIVILEGES;
```
3. Set environment variables:
```bash
export DB_HOST=localhost
export DB_USER=cms_user
export DB_PASSWORD=secure_password
export DB_NAME=blogging_cms
export PORT=8080
export SITE_URL=http://localhost:8080
export SESSION_KEY=generate-random-key-here
```
4. Run the application:
```bash
go run main.go
```
## Deployment on Coolify
### Prerequisites
- Coolify instance running
- Git repository (GitHub, GitLab, Gitea, etc.)
- Domain name (optional but recommended)
### Steps
1. **Create Coolify Project**
- New Project → Select "Docker Compose"
2. **Connect Repository**
- Link your Git repository with the blogging-cms code
- Point to the root directory
3. **Configure Environment**
- Add environment variables from `.env.example`:
```
DB_HOST=mariadb
DB_USER=cms_user
DB_PASSWORD=your_secure_password
DB_NAME=blogging_cms
SITE_URL=https://your-domain.com
SESSION_KEY=generate-random-key
```
4. **Deploy**
- Coolify automatically picks up `docker-compose.yml`
- Sets up MariaDB and Go application
- Configure reverse proxy (Caddy) in Coolify settings
5. **Health Checks**
- Coolify monitors `/` endpoint for health
- Application includes built-in healthcheck
## API Reference
### Authentication
**POST** `/auth/register`
```json
{
"username": "author",
"email": "author@example.com",
"password": "secure_password"
}
```
**POST** `/auth/login`
```json
{
"email": "author@example.com",
"password": "secure_password"
}
```
### Posts
**GET** `/api/posts?page=1`
- Get paginated list of published posts
**GET** `/api/posts/{id}`
- Get single post by ID
**POST** `/api/posts`
- Create new post (requires auth)
```json
{
"title": "Post Title",
"content": "# Markdown content",
"excerpt": "Brief summary",
"type": "post",
"category": "Technology",
"tags": ["go", "blogging"],
"published": false
}
```
**PUT** `/api/posts/{id}`
- Update post (requires ownership)
**DELETE** `/api/posts/{id}`
- Delete post (requires ownership)
### Comments
**POST** `/api/comments`
```json
{
"post_id": "post-uuid",
"content": "Comment text"
}
```
### Search
**GET** `/api/search?q=query`
- Full-text search across posts
### Feeds
**GET** `/feed`
- RSS feed of all published posts
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DB_HOST` | Database hostname | `mariadb` |
| `DB_PORT` | Database port | `3306` |
| `DB_USER` | Database user | `cms_user` |
| `DB_PASSWORD` | Database password | Required |
| `DB_NAME` | Database name | `blogging_cms` |
| `PORT` | Application port | `8080` |
| `SITE_URL` | Public site URL | `http://localhost:8080` |
| `SESSION_KEY` | Session encryption key | `dev-session-key` |
## Development
### Local Development
```bash
# Install Go
brew install go # macOS
# or download from golang.org
# Clone repository
git clone <repo-url>
cd blogging-cms
# Install dependencies
go mod tidy
# Start MariaDB (Docker)
docker run -d \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=blogging_cms \
-p 3306:3306 \
mariadb:11.0
# Run application
go run main.go
```
### Building Docker Image
```bash
docker build -t blogging-cms:latest .
```
### Testing
```bash
# Unit tests (not yet implemented)
go test ./...
# Integration tests with docker-compose
docker-compose -f docker-compose.test.yml up
```
## Markdown Syntax
Supported Markdown features:
- **Headers**: `# H1`, `## H2`, etc.
- **Bold**: `**text**`
- **Italic**: `*text*`
- **Code**: `` `inline` `` or code blocks with triple backticks
- **Lists**: `- item` or `1. item`
- **Links**: `[text](url)`
- **Images**: `![alt](url)`
- **Blockquotes**: `> quote`
## Security Considerations
- Passwords are hashed (consider using bcrypt in production)
- Session tokens are encrypted
- CSRF protection via sessions
- SQL injection protection via prepared statements
- XSS protection via template escaping
- Comments are moderated before publication
### Hardening for Production
1. **Use HTTPS** Configure in reverse proxy (Caddy)
2. **Change SESSION_KEY** Generate secure random key
3. **Use strong DB password** At least 32 characters
4. **Enable backups** MariaDB volume backups
5. **Rate limiting** Add reverse proxy rules
6. **Content Security Policy** Configure in Dockerfile
## Performance
- Lightweight Go binary (~10MB)
- Efficient MariaDB queries with indexing
- Static asset caching
- Lazy-loaded comments
- Paginated post listings
## Troubleshooting
### Database Connection Error
```
Error: dial tcp mariadb:3306: connect: connection refused
```
Solution: Ensure MariaDB container is running and healthy
```bash
docker-compose logs mariadb
```
### Posts Not Displaying
- Check `published=true` in database
- Verify database query with: `mysql -h mariadb -u cms_user -p`
### Static Files 404
- Ensure `static/` and `templates/` directories exist
- Check file permissions: `ls -la static/`
## Contributing
1. Fork the repository
2. Create feature branch: `git checkout -b feature/name`
3. Commit changes: `git commit -m "Add feature"`
4. Push: `git push origin feature/name`
5. Create Pull Request
## License
MIT License. See LICENSE file for details.
## Acknowledgments
- Material Design 3 guidelines
- Go standard library
- Gorilla toolkit
- Docker community
## Author
**Kalvin** Civic technologist and digital sovereignty advocate.
- GitHub: [@kalvin0x8d0](https://github.com/kalvin0x8d0)
- Fediverse: [@kalvin@social.obulou.org](https://social.obulou.org/@kalvin)
- Personal site: [obulou.org](https://obulou.org)
---
**Remember**: This is your space. Own your content. Self-host with care. ✍️
+61
View File
@@ -0,0 +1,61 @@
version: '3.8'
services:
mariadb:
image: mariadb:11.0-alpine
container_name: blogging-cms-db
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password}
MYSQL_DATABASE: ${DB_NAME:-blogging_cms}
MYSQL_USER: ${DB_USER:-cms_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-cms_password}
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
networks:
- blogging-network
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
app:
build:
context: .
dockerfile: Dockerfile
container_name: blogging-cms-app
depends_on:
mariadb:
condition: service_healthy
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_USER: ${DB_USER:-cms_user}
DB_PASSWORD: ${DB_PASSWORD:-cms_password}
DB_NAME: ${DB_NAME:-blogging_cms}
PORT: ${PORT:-8080}
SITE_URL: ${SITE_URL:-http://localhost:8080}
SESSION_KEY: ${SESSION_KEY:-change-me-in-production}
volumes:
- ./templates:/app/templates:ro
- ./static:/app/static:ro
ports:
- "${PORT:-8080}:8080"
networks:
- blogging-network
restart: unless-stopped
labels:
coolify.enabled: "true"
coolify.pull_request.deploy_preview: "false"
volumes:
mariadb_data:
driver: local
networks:
blogging-network:
driver: bridge
+16
View File
@@ -0,0 +1,16 @@
module github.com/kalvin0x8d0/blogging-cms
go 1.21
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/sessions v1.2.1
github.com/joho/godotenv v1.5.1
github.com/markdownit/markdown-it-go v0.5.1
github.com/google/uuid v1.3.1
)
require (
github.com/gorilla/securecookie v1.1.1 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0My1j6jdtAwbMawl2h3+lW0svZo=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sQlymrnBoFHjc9/GtNAcFNTfyhbNJwmsFLQ8=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlqK12yvrxwZjC/fm9AFSnLoqWFn1j4=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpwqqkhL41XzIq1P21kFQX6Y75DXkQh73g=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz6qkKCSD1D84xfJlpWYV2g34V4=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMust+tDkEmünchen=
github.com/gorilla/sessions v1.2.1 h1:DHd3rIP1McMspIoEPQDkAQCW4V3G7MoLo1zcu+odLc=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ7fscCTYNQCCCmwezX9E/c0GSA0TDx7h8M=
github.com/joho/godotenv v1.5.1 h1:7P4SfwW3eca4FsxyRVU1S3pjpijIXXw5Bj0nRoQxQM=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DxwEWRW0=
github.com/markdownit/markdown-it-go v0.5.1 h1:CqeYIrj9MmXsvKvdqGP1F0ksqJqBQ+gqhRAQV78/nE=
github.com/markdownit/markdown-it-go v0.5.1/go.mod h1:9fVGJQ8ZNF9Q4gv4v3fU2cZ3pWmxY8X3Xjbz4sTdOo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4lLFQMfcKzm6Hs3iFc7F8E=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGkdooKSUFSvJ9iCg6+RLA=
+684
View File
@@ -0,0 +1,684 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/joho/godotenv"
markdown "github.com/markdownit/markdown-it-go"
)
var (
db *sql.DB
sessionStore *sessions.CookieStore
markdownParser *markdown.MarkdownIt
)
// Models
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Role string `json:"role"` // admin, author, reader
CreatedAt time.Time `json:"created_at"`
}
type Post struct {
ID string `json:"id"`
AuthorID string `json:"author_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"` // post, micro
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Comment struct {
ID string `json:"id"`
PostID string `json:"post_id"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Approved bool `json:"approved"`
CreatedAt time.Time `json:"created_at"`
}
type APIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func init() {
// Load environment variables
godotenv.Load()
// Initialize markdown parser
markdownParser = markdown.New()
// Initialize session store
sessionStore = sessions.NewCookieStore([]byte(getEnv("SESSION_KEY", "dev-session-key")))
}
func main() {
// Database connection
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
getEnv("DB_USER", "root"),
getEnv("DB_PASSWORD", "password"),
getEnv("DB_HOST", "mariadb"),
getEnv("DB_PORT", "3306"),
getEnv("DB_NAME", "blogging_cms"),
)
var err error
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Database connection failed: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("Database ping failed: %v", err)
}
log.Println("Database connected successfully")
// Initialize database schema
if err := initDB(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Router setup
router := mux.NewRouter()
// Static files
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Public routes
router.HandleFunc("/", handleHome).Methods("GET")
router.HandleFunc("/post/{slug}", handleViewPost).Methods("GET")
router.HandleFunc("/category/{category}", handleCategory).Methods("GET")
router.HandleFunc("/feed", handleRSSFeed).Methods("GET")
router.HandleFunc("/api/posts", handleGetPosts).Methods("GET")
router.HandleFunc("/api/posts/{id}", handleGetPost).Methods("GET")
router.HandleFunc("/api/search", handleSearch).Methods("GET")
// Auth routes
router.HandleFunc("/auth/register", handleRegister).Methods("POST")
router.HandleFunc("/auth/login", handleLogin).Methods("POST")
router.HandleFunc("/auth/logout", handleLogout).Methods("POST")
// Protected routes
router.HandleFunc("/dashboard", handleDashboard).Methods("GET")
router.HandleFunc("/api/posts", handleCreatePost).Methods("POST")
router.HandleFunc("/api/posts/{id}", handleUpdatePost).Methods("PUT")
router.HandleFunc("/api/posts/{id}", handleDeletePost).Methods("DELETE")
router.HandleFunc("/api/comments", handleCreateComment).Methods("POST")
// Middleware
router.Use(loggingMiddleware)
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, router))
}
func initDB() error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'author',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id VARCHAR(36) PRIMARY KEY,
author_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content LONGTEXT NOT NULL,
excerpt VARCHAR(500),
type VARCHAR(50) DEFAULT 'post',
category VARCHAR(100),
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id),
INDEX (slug),
INDEX (published),
INDEX (created_at),
INDEX (category)
);
CREATE TABLE IF NOT EXISTS tags (
id VARCHAR(36) PRIMARY KEY,
post_id VARCHAR(36) NOT NULL,
tag VARCHAR(100),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comments (
id VARCHAR(36) PRIMARY KEY,
post_id VARCHAR(36) NOT NULL,
author_id VARCHAR(36),
content TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
`
for _, statement := range strings.Split(schema, ";") {
statement = strings.TrimSpace(statement)
if statement != "" {
if _, err := db.Exec(statement); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
}
}
return nil
}
// Handlers
func handleHome(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT 20
`)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "home.html", map[string]interface{}{
"posts": posts,
})
}
func handleViewPost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
var post Post
var authorUsername string
var commentCount int
err := db.QueryRow(`
SELECT p.id, p.author_id, p.title, p.slug, p.content, p.excerpt, p.type, p.category, p.created_at, p.updated_at, u.username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.slug = ? AND p.published = TRUE
`, slug).Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Content, &post.Excerpt, &post.Type, &post.Category, &post.CreatedAt, &post.UpdatedAt, &authorUsername)
if err != nil {
http.NotFound(w, r)
return
}
// Render markdown
post.Content = markdownParser.Render(post.Content)
// Get tags
tagRows, _ := db.Query("SELECT tag FROM tags WHERE post_id = ?", post.ID)
defer tagRows.Close()
for tagRows.Next() {
var tag string
tagRows.Scan(&tag)
post.Tags = append(post.Tags, tag)
}
// Get comments
commentRows, _ := db.Query("SELECT COUNT(*) FROM comments WHERE post_id = ? AND approved = TRUE", post.ID)
commentRows.Next()
commentRows.Scan(&commentCount)
commentRows.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "post.html", map[string]interface{}{
"post": post,
"author": authorUsername,
"commentCount": commentCount,
})
}
func handleCategory(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
category := vars["category"]
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE AND category = ?
ORDER BY created_at DESC
`, category)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "category.html", map[string]interface{}{
"category": category,
"posts": posts,
})
}
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
pageStr := r.URL.Query().Get("page")
page := 1
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
limit := 20
offset := (page - 1) * limit
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, category, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Database error"})
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.Category, &post.CreatedAt)
posts = append(posts, post)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: posts})
}
func handleGetPost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
var post Post
err := db.QueryRow(`
SELECT id, author_id, title, slug, content, excerpt, type, category, published, created_at, updated_at
FROM posts
WHERE id = ?
`, postID).Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Content, &post.Excerpt, &post.Type, &post.Category, &post.Published, &post.CreatedAt, &post.UpdatedAt)
if err != nil {
respondJSON(w, http.StatusNotFound, APIResponse{Success: false, Message: "Post not found"})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: post})
}
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"`
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
postID := uuid.New().String()
slug := slugify(req.Title)
_, err := db.Exec(`
INSERT INTO posts (id, author_id, title, slug, content, excerpt, type, category, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, postID, userID, req.Title, slug, req.Content, req.Excerpt, req.Type, req.Category, req.Published)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to create post"})
return
}
// Save tags
for _, tag := range req.Tags {
db.Exec("INSERT INTO tags (id, post_id, tag) VALUES (?, ?, ?)", uuid.New().String(), postID, tag)
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "Post created", Data: map[string]string{"id": postID}})
}
func handleUpdatePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Type string `json:"type"`
Category string `json:"category"`
Tags []string `json:"tags"`
Published bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
// Verify ownership
var authorID string
db.QueryRow("SELECT author_id FROM posts WHERE id = ?", postID).Scan(&authorID)
if authorID != userID {
respondJSON(w, http.StatusForbidden, APIResponse{Success: false, Message: "Forbidden"})
return
}
slug := slugify(req.Title)
_, err := db.Exec(`
UPDATE posts
SET title = ?, slug = ?, content = ?, excerpt = ?, type = ?, category = ?, published = ?
WHERE id = ?
`, req.Title, slug, req.Content, req.Excerpt, req.Type, req.Category, req.Published, postID)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to update post"})
return
}
// Update tags
db.Exec("DELETE FROM tags WHERE post_id = ?", postID)
for _, tag := range req.Tags {
db.Exec("INSERT INTO tags (id, post_id, tag) VALUES (?, ?, ?)", uuid.New().String(), postID, tag)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Post updated"})
}
func handleDeletePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postID := vars["id"]
userID, ok := r.Context().Value("userID").(string)
if !ok {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Unauthorized"})
return
}
var authorID string
db.QueryRow("SELECT author_id FROM posts WHERE id = ?", postID).Scan(&authorID)
if authorID != userID {
respondJSON(w, http.StatusForbidden, APIResponse{Success: false, Message: "Forbidden"})
return
}
_, err := db.Exec("DELETE FROM posts WHERE id = ?", postID)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete post"})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Post deleted"})
}
func handleCreateComment(w http.ResponseWriter, r *http.Request) {
var req struct {
PostID string `json:"post_id"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
commentID := uuid.New().String()
_, err := db.Exec(`
INSERT INTO comments (id, post_id, content, approved)
VALUES (?, ?, ?, FALSE)
`, commentID, req.PostID, req.Content)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to create comment"})
return
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "Comment submitted for moderation"})
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Search query required"})
return
}
searchTerm := "%" + query + "%"
rows, err := db.Query(`
SELECT id, author_id, title, slug, excerpt, type, created_at
FROM posts
WHERE published = TRUE AND (title LIKE ? OR content LIKE ? OR excerpt LIKE ?)
ORDER BY created_at DESC
LIMIT 50
`, searchTerm, searchTerm, searchTerm)
if err != nil {
respondJSON(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Search failed"})
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var post Post
rows.Scan(&post.ID, &post.AuthorID, &post.Title, &post.Slug, &post.Excerpt, &post.Type, &post.CreatedAt)
posts = append(posts, post)
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: posts})
}
func handleRSSFeed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
rows, _ := db.Query(`
SELECT id, title, slug, excerpt, created_at
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT 20
`)
defer rows.Close()
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Blog</title>
<link>`)
fmt.Fprint(w, getEnv("SITE_URL", "http://localhost:8080"))
fmt.Fprint(w, `</link>
<description>Latest posts</description>
`)
for rows.Next() {
var id, title, slug, excerpt string
var createdAt time.Time
rows.Scan(&id, &title, &slug, &excerpt, &createdAt)
fmt.Fprintf(w, `<item>
<title>%s</title>
<link>%s/post/%s</link>
<description>%s</description>
<pubDate>%s</pubDate>
</item>
`, title, getEnv("SITE_URL", "http://localhost:8080"), slug, excerpt, createdAt.Format(time.RFC1123Z))
}
fmt.Fprint(w, `</channel>
</rss>`)
}
func handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
userID := uuid.New().String()
passwordHash := hashPassword(req.Password)
_, err := db.Exec(`
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, ?, 'author')
`, userID, req.Username, req.Email, passwordHash)
if err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Registration failed"})
return
}
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Message: "User created"})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
var userID, passwordHash string
err := db.QueryRow("SELECT id, password_hash FROM users WHERE email = ?", req.Email).Scan(&userID, &passwordHash)
if err != nil || !verifyPassword(req.Password, passwordHash) {
respondJSON(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
return
}
session, _ := sessionStore.Get(r, "auth")
session.Values["userID"] = userID
session.Save(r, w)
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Logged in"})
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, "auth")
session.Options.MaxAge = -1
session.Save(r, w)
respondJSON(w, http.StatusOK, APIResponse{Success: true, Message: "Logged out"})
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderTemplate(w, "dashboard.html", nil)
}
// Utility functions
func getEnv(key, defaultVal string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultVal
}
func slugify(s string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), " ", "-"))
}
func hashPassword(password string) string {
// In production, use bcrypt
return password
}
func verifyPassword(password, hash string) bool {
// In production, use bcrypt
return password == hash
}
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
func renderTemplate(w http.ResponseWriter, name string, data interface{}) {
t, err := template.ParseFiles("templates/" + name)
if err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
t.Execute(w, data)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.RequestURI, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
+514
View File
@@ -0,0 +1,514 @@
/* Material Design 3 Tokens */
:root {
/* Primary colors */
--md-sys-color-primary: #6750a4;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #eaddff;
--md-sys-color-on-primary-container: #21005e;
/* Secondary colors */
--md-sys-color-secondary: #625b71;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #e8def8;
--md-sys-color-on-secondary-container: #1e192b;
/* Tertiary colors */
--md-sys-color-tertiary: #7d5260;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #ffd8e4;
--md-sys-color-on-tertiary-container: #31111d;
/* Neutral colors */
--md-sys-color-background: #fffbfe;
--md-sys-color-on-background: #1c1b1f;
--md-sys-color-surface: #fffbfe;
--md-sys-color-on-surface: #1c1b1f;
--md-sys-color-surface-variant: #e7e0ec;
--md-sys-color-on-surface-variant: #49454e;
--md-sys-color-outline: #79747e;
--md-sys-color-outline-variant: #c4c7c5;
/* Error colors */
--md-sys-color-error: #b3261e;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #f9dedc;
--md-sys-color-on-error-container: #410e0b;
/* Typography */
--md-sys-typescale-display-large-font-family: 'Roboto', sans-serif;
--md-sys-typescale-display-large-font-size: 57px;
--md-sys-typescale-display-large-font-weight: 400;
--md-sys-typescale-display-large-line-height: 64px;
--md-sys-typescale-headline-large-font-size: 32px;
--md-sys-typescale-headline-large-font-weight: 400;
--md-sys-typescale-headline-large-line-height: 40px;
--md-sys-typescale-headline-medium-font-size: 28px;
--md-sys-typescale-headline-medium-font-weight: 400;
--md-sys-typescale-headline-medium-line-height: 36px;
--md-sys-typescale-headline-small-font-size: 24px;
--md-sys-typescale-headline-small-font-weight: 400;
--md-sys-typescale-headline-small-line-height: 32px;
--md-sys-typescale-title-large-font-size: 22px;
--md-sys-typescale-title-large-font-weight: 500;
--md-sys-typescale-title-large-line-height: 28px;
--md-sys-typescale-title-medium-font-size: 16px;
--md-sys-typescale-title-medium-font-weight: 500;
--md-sys-typescale-title-medium-line-height: 24px;
--md-sys-typescale-body-large-font-size: 16px;
--md-sys-typescale-body-large-font-weight: 400;
--md-sys-typescale-body-large-line-height: 24px;
--md-sys-typescale-body-medium-font-size: 14px;
--md-sys-typescale-body-medium-font-weight: 500;
--md-sys-typescale-body-medium-line-height: 20px;
--md-sys-typescale-label-large-font-size: 14px;
--md-sys-typescale-label-large-font-weight: 500;
--md-sys-typescale-label-large-line-height: 20px;
}
/* Global styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
font-size: var(--md-sys-typescale-body-large-font-size);
line-height: var(--md-sys-typescale-body-large-line-height);
}
/* Typography */
h1 {
font-size: var(--md-sys-typescale-display-large-font-size);
font-weight: var(--md-sys-typescale-display-large-font-weight);
line-height: var(--md-sys-typescale-display-large-line-height);
margin: 1em 0 0.5em 0;
}
h2 {
font-size: var(--md-sys-typescale-headline-large-font-size);
font-weight: var(--md-sys-typescale-headline-large-font-weight);
line-height: var(--md-sys-typescale-headline-large-line-height);
margin: 1em 0 0.5em 0;
}
h3 {
font-size: var(--md-sys-typescale-headline-medium-font-size);
font-weight: var(--md-sys-typescale-headline-medium-font-weight);
line-height: var(--md-sys-typescale-headline-medium-line-height);
margin: 0.8em 0 0.4em 0;
}
h4, h5, h6 {
font-size: var(--md-sys-typescale-headline-small-font-size);
font-weight: var(--md-sys-typescale-headline-small-font-weight);
line-height: var(--md-sys-typescale-headline-small-line-height);
margin: 0.8em 0 0.4em 0;
}
p {
margin-bottom: 1em;
}
a {
color: var(--md-sys-color-primary);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--md-sys-color-on-primary-container);
text-decoration: underline;
}
/* Components */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
}
.card {
background-color: var(--md-sys-color-surface);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--md-sys-color-outline-variant);
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0 24px;
border: none;
border-radius: 24px;
font-size: var(--md-sys-typescale-label-large-font-size);
font-weight: var(--md-sys-typescale-label-large-font-weight);
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.button--primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.button--primary:hover {
background-color: #5a4799;
}
.button--primary:active {
background-color: #4f3d8a;
}
.button--secondary {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.button--secondary:hover {
background-color: #d5cce1;
}
.button--tertiary {
background-color: transparent;
color: var(--md-sys-color-primary);
border: 2px solid var(--md-sys-color-outline);
}
.button--tertiary:hover {
background-color: var(--md-sys-color-primary-container);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Forms */
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-size: var(--md-sys-typescale-body-medium-font-size);
font-weight: var(--md-sys-typescale-body-medium-font-weight);
margin-bottom: 8px;
color: var(--md-sys-color-on-background);
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
textarea,
select {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-size: var(--md-sys-typescale-body-large-font-size);
font-family: inherit;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.1);
}
textarea {
resize: vertical;
min-height: 120px;
}
/* Header */
.header {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
padding: 24px 0;
margin-bottom: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header__content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
}
.header__logo {
font-size: var(--md-sys-typescale-headline-large-font-size);
font-weight: 600;
color: inherit;
text-decoration: none;
}
.header__nav {
display: flex;
gap: 32px;
align-items: center;
}
.header__nav a {
color: inherit;
text-decoration: none;
font-size: var(--md-sys-typescale-body-large-font-size);
transition: opacity 0.2s ease;
}
.header__nav a:hover {
opacity: 0.8;
}
/* Post list */
.post-list {
list-style: none;
}
.post-item {
padding: 24px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
transition: background-color 0.2s ease;
}
.post-item:hover {
background-color: var(--md-sys-color-surface-variant);
}
.post-item:last-child {
border-bottom: none;
}
.post-title {
font-size: var(--md-sys-typescale-title-large-font-size);
font-weight: var(--md-sys-typescale-title-large-font-weight);
margin-bottom: 8px;
}
.post-meta {
font-size: var(--md-sys-typescale-body-medium-font-size);
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 12px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.post-excerpt {
margin-bottom: 12px;
color: var(--md-sys-color-on-background);
}
.post-type {
display: inline-block;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
.post-type--micro {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* Tags */
.tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.tag {
display: inline-block;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
transition: all 0.2s ease;
}
.tag:hover {
background-color: var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface);
}
/* Footer */
.footer {
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
padding: 40px 0 24px;
margin-top: 60px;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
.footer__content {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 32px;
margin-bottom: 32px;
}
.footer__section h4 {
margin-top: 0;
color: var(--md-sys-color-on-surface);
}
.footer__section ul {
list-style: none;
}
.footer__section ul li {
margin-bottom: 8px;
}
.footer__section a {
color: var(--md-sys-color-on-surface-variant);
}
.footer__section a:hover {
color: var(--md-sys-color-primary);
text-decoration: underline;
}
.footer__bottom {
text-align: center;
padding-top: 24px;
border-top: 1px solid var(--md-sys-color-outline-variant);
font-size: var(--md-sys-typescale-body-medium-font-size);
}
/* Responsive */
@media (max-width: 768px) {
.header__content {
flex-direction: column;
gap: 16px;
}
.header__nav {
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
h1 {
font-size: 40px;
}
h2 {
font-size: 28px;
}
.button {
width: 100%;
}
}
/* Code highlighting */
pre {
background-color: var(--md-sys-color-surface-variant);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 16px 0;
}
code {
font-family: 'Courier New', monospace;
font-size: 14px;
}
pre code {
background-color: transparent;
color: var(--md-sys-color-on-background);
}
/* Utility classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.gap-16 {
gap: 16px;
}
.gap-32 {
gap: 32px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.mb-16 {
margin-bottom: 16px;
}
.mb-24 {
margin-bottom: 24px;
}
+472
View File
@@ -0,0 +1,472 @@
// API Base
const API_BASE = '/api';
// Utility functions
const api = {
async get(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async post(endpoint, data) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async put(endpoint, data) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async delete(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
};
// Toast notifications
class Toast {
static show(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
animation: slideIn 0.3s ease;
border-left: 4px solid var(--md-sys-color-primary);
`;
if (type === 'success') {
toast.style.borderLeftColor = '#4caf50';
} else if (type === 'error') {
toast.style.borderLeftColor = 'var(--md-sys-color-error)';
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
}
}
// Modal dialog
class Modal {
constructor(title, content) {
this.title = title;
this.content = content;
this.element = null;
}
open() {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
animation: fadeIn 0.2s ease;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background-color: var(--md-sys-color-surface);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease;
`;
const titleEl = document.createElement('h2');
titleEl.textContent = this.title;
titleEl.style.cssText = 'margin: 0 0 16px 0;';
const contentEl = document.createElement('div');
contentEl.innerHTML = this.content;
contentEl.style.cssText = 'margin-bottom: 24px;';
dialog.appendChild(titleEl);
dialog.appendChild(contentEl);
modal.appendChild(dialog);
modal.addEventListener('click', (e) => {
if (e.target === modal) this.close();
});
this.element = modal;
document.body.appendChild(modal);
}
close() {
if (this.element) {
this.element.style.animation = 'fadeOut 0.2s ease';
setTimeout(() => this.element.remove(), 200);
}
}
}
// Post editor
class PostEditor {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.postId = null;
this.init();
}
init() {
this.container.innerHTML = `
<form id="post-form" class="card">
<h2>Create New Post</h2>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" required placeholder="Post title">
</div>
<div class="form-group">
<label for="type">Post Type</label>
<select id="type" name="type">
<option value="post">Blog Post</option>
<option value="micro">Micro Post</option>
</select>
</div>
<div class="form-group">
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="e.g., Technology, Personal">
</div>
<div class="form-group">
<label for="excerpt">Excerpt</label>
<textarea id="excerpt" name="excerpt" placeholder="Brief summary of the post"></textarea>
</div>
<div class="form-group">
<label for="content">Content (Markdown)</label>
<textarea id="content" name="content" required placeholder="Write your content in Markdown..."></textarea>
<div style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); margin-top: 8px;">
Supports Markdown syntax. Preview available.
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" placeholder="tag1, tag2, tag3">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="published" name="published">
Publish immediately
</label>
</div>
<div style="display: flex; gap: 16px;">
<button type="submit" class="button button--primary">Save Post</button>
<button type="button" id="preview-btn" class="button button--secondary">Preview</button>
</div>
</form>
`;
this.form = document.getElementById('post-form');
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
document.getElementById('preview-btn').addEventListener('click', () => this.showPreview());
}
async handleSubmit(e) {
e.preventDefault();
const formData = {
title: document.getElementById('title').value,
content: document.getElementById('content').value,
excerpt: document.getElementById('excerpt').value,
type: document.getElementById('type').value,
category: document.getElementById('category').value,
tags: document.getElementById('tags').value.split(',').map(t => t.trim()).filter(t => t),
published: document.getElementById('published').checked,
};
try {
const endpoint = this.postId ? `/posts/${this.postId}` : '/posts';
const method = this.postId ? 'put' : 'post';
const result = await api[method](endpoint, formData);
if (result.success) {
Toast.show('Post saved successfully!', 'success');
if (!this.postId) {
this.postId = result.data.id;
}
setTimeout(() => window.location.href = '/', 1500);
}
} catch (error) {
Toast.show('Error saving post: ' + error.message, 'error');
}
}
showPreview() {
const content = document.getElementById('content').value;
const title = document.getElementById('title').value;
const previewHtml = `
<div style="padding: 24px;">
<h1>${title || 'Untitled'}</h1>
<div id="preview-content" style="margin-top: 24px;">
${this.markdownToHtml(content)}
</div>
</div>
`;
const modal = new Modal('Preview', previewHtml);
modal.open();
}
markdownToHtml(markdown) {
let html = markdown
.replace(/^### (.*?)$/gm, '<h3>$1</h3>')
.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/gm, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gm, '<em>$1</em>')
.replace(/`(.*?)`/gm, '<code>$1</code>')
.replace(/\n\n/gm, '</p><p>')
.replace(/^/gm, '<p>')
.replace(/$/gm, '</p>');
return html;
}
}
// Posts list
class PostsList {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.page = 1;
this.loading = false;
this.init();
}
async init() {
this.container.innerHTML = '<div style="text-align: center; padding: 40px;">Loading posts...</div>';
await this.loadPosts();
}
async loadPosts() {
if (this.loading) return;
this.loading = true;
try {
const result = await api.get(`/posts?page=${this.page}`);
if (result.success && result.data) {
this.render(result.data);
}
} catch (error) {
this.container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--md-sys-color-error);">Error loading posts</div>`;
}
this.loading = false;
}
render(posts) {
if (posts.length === 0) {
this.container.innerHTML = '<div style="text-align: center; padding: 40px;">No posts yet.</div>';
return;
}
let html = '<ul class="post-list">';
posts.forEach(post => {
const date = new Date(post.created_at).toLocaleDateString();
html += `
<li class="post-item">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<h3 class="post-title">
<a href="/post/${post.slug}">${post.title}</a>
</h3>
<span class="post-type ${post.type === 'micro' ? 'post-type--micro' : ''}">
${post.type === 'micro' ? '💬' : '📝'} ${post.type}
</span>
</div>
<div class="post-meta">
<span>${date}</span>
${post.category ? `<span>${post.category}</span>` : ''}
</div>
<p class="post-excerpt">${post.excerpt || post.content.substring(0, 200)}</p>
<a href="/post/${post.slug}" style="font-weight: 500;">Read more →</a>
</li>
`;
});
html += '</ul>';
this.container.innerHTML = html;
// Pagination
if (posts.length >= 20) {
const paginationBtn = document.createElement('div');
paginationBtn.style.cssText = 'text-align: center; margin-top: 40px;';
paginationBtn.innerHTML = `
<button class="button button--secondary" id="load-more-btn">Load More Posts</button>
`;
this.container.appendChild(paginationBtn);
document.getElementById('load-more-btn').addEventListener('click', () => {
this.page++;
this.loadPosts();
});
}
}
}
// Search functionality
class SearchBox {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.init();
}
init() {
this.input = this.container.querySelector('input[type="search"]');
if (!this.input) return;
this.input.addEventListener('keyup', (e) => this.search(e.target.value));
}
async search(query) {
if (!query || query.length < 2) {
this.clearResults();
return;
}
try {
const result = await api.get(`/search?q=${encodeURIComponent(query)}`);
this.renderResults(result.data || []);
} catch (error) {
console.error('Search error:', error);
}
}
renderResults(posts) {
let html = '<div style="position: absolute; top: 100%; left: 0; right: 0; background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 100; max-height: 400px; overflow-y: auto;">';
posts.slice(0, 10).forEach(post => {
html += `
<a href="/post/${post.slug}" style="display: block; padding: 12px 16px; border-bottom: 1px solid #eee; color: inherit; text-decoration: none;">
<div style="font-weight: 500;">${post.title}</div>
<div style="font-size: 12px; color: #666;">${new Date(post.created_at).toLocaleDateString()}</div>
</a>
`;
});
html += '</div>';
let dropdown = document.getElementById('search-results');
if (dropdown) dropdown.remove();
const resultsDiv = document.createElement('div');
resultsDiv.id = 'search-results';
resultsDiv.innerHTML = html;
resultsDiv.style.cssText = 'position: relative;';
this.container.appendChild(resultsDiv);
}
clearResults() {
const dropdown = document.getElementById('search-results');
if (dropdown) dropdown.remove();
}
}
// Initialize on document ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize any editors or lists if they exist
if (document.getElementById('post-editor')) {
new PostEditor('post-editor');
}
if (document.getElementById('posts-list')) {
new PostsList('posts-list');
}
if (document.getElementById('search-box')) {
new SearchBox('search-box');
}
});
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`;
document.head.appendChild(style);
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.category}} - Blog</title>
<link rel="stylesheet" href="/static/css/material.css">
</head>
<body>
<header class="header">
<div class="header__content">
<a href="/" class="header__logo">📝 Blog</a>
<nav class="header__nav">
<a href="/">Home</a>
<a href="#about">About</a>
<a href="/feed">RSS Feed</a>
<a href="/dashboard">Dashboard</a>
</nav>
</div>
</header>
<main class="container" style="padding: 40px 16px;">
<div style="margin-bottom: 40px;">
<h1>{{.category}}</h1>
<p style="font-size: 18px; color: var(--md-sys-color-on-surface-variant);">
Posts in the {{.category}} category
</p>
</div>
<div style="max-width: 800px;">
{{if .posts}}
<ul class="post-list">
{{range .posts}}
<li class="post-item">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<h3 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
</h3>
<span class="post-type {{if eq .Type "micro"}}post-type--micro{{end}}">
{{if eq .Type "micro"}}💬{{else}}📝{{end}} {{.Type}}
</span>
</div>
<div class="post-meta">
<span>{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
<p class="post-excerpt">{{.Excerpt}}</p>
<a href="/post/{{.Slug}}" style="font-weight: 500;">Read more →</a>
</li>
{{end}}
</ul>
{{else}}
<div style="padding: 40px; text-align: center; color: var(--md-sys-color-on-surface-variant);">
<p>No posts in this category yet.</p>
<a href="/" class="button button--primary" style="margin-top: 16px;">← Back to Home</a>
</div>
{{end}}
</div>
</main>
<footer class="footer">
<div class="footer__content">
<div class="footer__section">
<h4>Category</h4>
<p>Browsing posts in {{.category}}</p>
</div>
</div>
<div class="footer__bottom">
<p>&copy; 2024. Self-hosted with care.</p>
</div>
</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Blog</title>
<link rel="stylesheet" href="/static/css/material.css">
</head>
<body>
<header class="header">
<div class="header__content">
<a href="/" class="header__logo">📝 Blog</a>
<nav class="header__nav">
<a href="/">Home</a>
<a href="#about">About</a>
<a href="/feed">RSS Feed</a>
<span style="padding: 8px 16px; background: rgba(255,255,255,0.2); border-radius: 20px;">Dashboard</span>
</nav>
</div>
</header>
<main class="container" style="padding: 40px 16px;">
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 40px; align-items: start;">
<!-- Sidebar Navigation -->
<aside style="position: sticky; top: 20px;">
<div class="card">
<h3 style="margin-top: 0;">Dashboard</h3>
<nav style="display: flex; flex-direction: column; gap: 8px;">
<button class="sidebar-btn active" data-view="editor" style="
text-align: left;
background: none;
border: none;
padding: 12px;
border-radius: 8px;
cursor: pointer;
color: var(--md-sys-color-primary);
font-weight: 500;
">
✍️ Write Post
</button>
<button class="sidebar-btn" data-view="posts" style="
text-align: left;
background: none;
border: none;
padding: 12px;
border-radius: 8px;
cursor: pointer;
color: var(--md-sys-color-on-background);
">
📚 My Posts
</button>
<button class="sidebar-btn" data-view="stats" style="
text-align: left;
background: none;
border: none;
padding: 12px;
border-radius: 8px;
cursor: pointer;
color: var(--md-sys-color-on-background);
">
📊 Statistics
</button>
<hr style="border: none; border-top: 1px solid var(--md-sys-color-outline-variant); margin: 12px 0;">
<button onclick="handleLogout()" style="
text-align: left;
background: none;
border: none;
padding: 12px;
border-radius: 8px;
cursor: pointer;
color: var(--md-sys-color-error);
">
🚪 Logout
</button>
</nav>
</div>
</aside>
<!-- Main Content -->
<section>
<!-- Editor View -->
<div id="editor-view" class="view-section" style="display: block;">
<div id="post-editor"></div>
</div>
<!-- Posts View -->
<div id="posts-view" class="view-section" style="display: none;">
<h2>My Posts</h2>
<div id="user-posts-list" style="margin-top: 24px;">
Loading your posts...
</div>
</div>
<!-- Statistics View -->
<div id="stats-view" class="view-section" style="display: none;">
<h2>Statistics</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 24px;">
<div class="card">
<div style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); text-transform: uppercase; letter-spacing: 0.5px;">Total Posts</div>
<div id="stat-total-posts" style="font-size: 32px; font-weight: 600; margin-top: 8px;">0</div>
</div>
<div class="card">
<div style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); text-transform: uppercase; letter-spacing: 0.5px;">Published</div>
<div id="stat-published-posts" style="font-size: 32px; font-weight: 600; margin-top: 8px;">0</div>
</div>
<div class="card">
<div style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); text-transform: uppercase; letter-spacing: 0.5px;">Drafts</div>
<div id="stat-draft-posts" style="font-size: 32px; font-weight: 600; margin-top: 8px;">0</div>
</div>
</div>
</div>
</section>
</div>
</main>
<footer class="footer">
<div class="footer__content">
<div class="footer__section">
<h4>Dashboard</h4>
<p>Manage your blog posts and content from here.</p>
</div>
</div>
<div class="footer__bottom">
<p>&copy; 2024. Self-hosted with care.</p>
</div>
</footer>
<script src="/static/js/app.js"></script>
<script>
// View switching
document.querySelectorAll('.sidebar-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const view = e.target.dataset.view;
// Update active button
document.querySelectorAll('.sidebar-btn').forEach(b => {
b.style.color = 'var(--md-sys-color-on-background)';
});
e.target.style.color = 'var(--md-sys-color-primary)';
// Update active view
document.querySelectorAll('.view-section').forEach(section => {
section.style.display = 'none';
});
document.getElementById(view + '-view').style.display = 'block';
// Load data for specific views
if (view === 'posts') {
loadUserPosts();
} else if (view === 'stats') {
loadStatistics();
} else if (view === 'editor') {
new PostEditor('post-editor');
}
});
});
// Initialize editor on first load
new PostEditor('post-editor');
async function loadUserPosts() {
try {
const result = await api.get('/posts?page=1');
const postsList = document.getElementById('user-posts-list');
if (!result.data || result.data.length === 0) {
postsList.innerHTML = '<p>No posts yet. Start writing!</p>';
return;
}
let html = '<ul class="post-list">';
result.data.forEach(post => {
html += `
<li class="post-item">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<h3 class="post-title" style="margin: 0 0 8px 0;">
<a href="/post/${post.slug}">${post.title}</a>
</h3>
<div class="post-meta" style="font-size: 12px;">
<span>${new Date(post.created_at).toLocaleDateString()}</span>
${post.category ? `<span>${post.category}</span>` : ''}
</div>
</div>
<div style="display: flex; gap: 8px;">
<button onclick="editPost('${post.id}')" class="button button--secondary" style="height: 32px; padding: 0 12px; font-size: 12px;">Edit</button>
<button onclick="deletePost('${post.id}')" class="button button--tertiary" style="height: 32px; padding: 0 12px; font-size: 12px; color: var(--md-sys-color-error);">Delete</button>
</div>
</div>
</li>
`;
});
html += '</ul>';
postsList.innerHTML = html;
} catch (error) {
document.getElementById('user-posts-list').innerHTML = '<p style="color: var(--md-sys-color-error);">Error loading posts</p>';
}
}
async function loadStatistics() {
try {
const result = await api.get('/posts?page=1');
const posts = result.data || [];
const total = posts.length;
const published = posts.filter(p => p.published).length;
const drafts = total - published;
document.getElementById('stat-total-posts').textContent = total;
document.getElementById('stat-published-posts').textContent = published;
document.getElementById('stat-draft-posts').textContent = drafts;
} catch (error) {
console.error('Error loading statistics:', error);
}
}
function editPost(postId) {
Toast.show('Edit functionality coming soon', 'info');
// TODO: Implement edit functionality
}
async function deletePost(postId) {
if (!confirm('Are you sure you want to delete this post?')) return;
try {
const result = await api.delete(`/posts/${postId}`);
if (result.success) {
Toast.show('Post deleted', 'success');
loadUserPosts();
}
} catch (error) {
Toast.show('Error deleting post', 'error');
}
}
async function handleLogout() {
try {
await api.post('/auth/logout', {});
Toast.show('Logged out', 'success');
setTimeout(() => window.location.href = '/', 1000);
} catch (error) {
Toast.show('Error logging out', 'error');
}
}
</script>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog - Kalvin's Corner</title>
<link rel="stylesheet" href="/static/css/material.css">
<link rel="alternate" type="application/rss+xml" title="Blog Feed" href="/feed">
</head>
<body>
<header class="header">
<div class="header__content">
<a href="/" class="header__logo">📝 Blog</a>
<nav class="header__nav">
<a href="/">Home</a>
<a href="#about">About</a>
<a href="/feed">RSS Feed</a>
<a href="/dashboard">Dashboard</a>
</nav>
</div>
</header>
<main class="container" style="padding: 40px 16px;">
<div style="margin-bottom: 40px;">
<h1>Welcome to the Blog</h1>
<p style="font-size: 18px; color: var(--md-sys-color-on-surface-variant);">
Essays, thoughts, and micro-posts on technology, digital sovereignty, and activism.
</p>
</div>
<div id="search-box" style="margin-bottom: 40px; position: relative;">
<input
type="search"
placeholder="Search posts..."
style="width: 100%; max-width: 500px;"
>
</div>
<div id="posts-list">
<!-- Posts will be loaded here -->
</div>
</main>
<footer class="footer">
<div class="footer__content">
<div class="footer__section">
<h4>About This Blog</h4>
<p>A self-hosted blogging platform for essays and thoughts on technology and activism.</p>
</div>
<div class="footer__section">
<h4>Navigation</h4>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/feed">RSS Feed</a></li>
<li><a href="/dashboard">Dashboard</a></li>
</ul>
</div>
<div class="footer__section">
<h4>Connect</h4>
<ul>
<li><a href="https://github.com">GitHub</a></li>
<li><a href="https://social.obulou.org">Fediverse</a></li>
</ul>
</div>
</div>
<div class="footer__bottom">
<p>&copy; 2024. Self-hosted with care. <a href="https://github.com/kalvin0x8d0/blogging-cms">View source</a></p>
</div>
</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
+134
View File
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.post.Title}} - Blog</title>
<link rel="stylesheet" href="/static/css/material.css">
</head>
<body>
<header class="header">
<div class="header__content">
<a href="/" class="header__logo">📝 Blog</a>
<nav class="header__nav">
<a href="/">Home</a>
<a href="#about">About</a>
<a href="/feed">RSS Feed</a>
<a href="/dashboard">Dashboard</a>
</nav>
</div>
</header>
<main class="container" style="padding: 40px 16px; max-width: 800px;">
<article>
<header style="margin-bottom: 40px;">
<h1 style="margin-top: 0;">{{.post.Title}}</h1>
<div class="post-meta" style="margin: 20px 0;">
<span>By {{.author}}</span>
<span>{{.post.CreatedAt.Format "2006-01-02"}}</span>
{{if .post.Category}}
<span><a href="/category/{{.post.Category}}">{{.post.Category}}</a></span>
{{end}}
<span>{{.commentCount}} comments</span>
</div>
{{if .post.Tags}}
<div class="tags">
{{range .post.Tags}}
<a href="/tag/{{.}}" class="tag">{{.}}</a>
{{end}}
</div>
{{end}}
</header>
<div style="
line-height: 1.8;
color: var(--md-sys-color-on-background);
margin-bottom: 40px;
">
{{.post.Content}}
</div>
</article>
<hr style="border: none; border-top: 1px solid var(--md-sys-color-outline-variant); margin: 40px 0;">
<section style="margin: 40px 0;">
<h2>Comments</h2>
<form id="comment-form" style="background: var(--md-sys-color-surface-variant); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<div class="form-group">
<label for="comment-content">Leave a comment</label>
<textarea
id="comment-content"
name="content"
placeholder="Your comment (will be moderated)"
required
style="min-height: 100px;"
></textarea>
</div>
<button type="submit" class="button button--primary">Post Comment</button>
</form>
<div id="comments-list">
<p style="color: var(--md-sys-color-on-surface-variant); font-style: italic;">
Comments are moderated and will appear after approval.
</p>
</div>
</section>
<div style="margin-top: 60px; padding-top: 40px; border-top: 1px solid var(--md-sys-color-outline-variant);">
<nav style="display: flex; justify-content: space-between;">
<a href="/" class="button button--secondary">← Back to posts</a>
</nav>
</div>
</main>
<footer class="footer">
<div class="footer__content">
<div class="footer__section">
<h4>About This Post</h4>
<p>Published on {{.post.CreatedAt.Format "January 2, 2006"}}.
{{if ne .post.UpdatedAt .post.CreatedAt}}
Updated on {{.post.UpdatedAt.Format "January 2, 2006"}}.
{{end}}
</p>
</div>
<div class="footer__section">
<h4>Share</h4>
<ul>
<li><a href="https://twitter.com/intent/tweet?url={{.post.Slug}}&text={{.post.Title}}" target="_blank">Share on X</a></li>
<li><a href="https://www.linkedin.com/sharing/share-offsite/?url={{.post.Slug}}" target="_blank">Share on LinkedIn</a></li>
</ul>
</div>
</div>
<div class="footer__bottom">
<p>&copy; 2024. Self-hosted with care.</p>
</div>
</footer>
<script src="/static/js/app.js"></script>
<script>
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const content = document.getElementById('comment-content').value;
const postID = '{{.post.ID}}';
try {
const result = await api.post('/comments', {
post_id: postID,
content: content
});
if (result.success) {
Toast.show('Comment submitted! It will appear after moderation.', 'success');
document.getElementById('comment-form').reset();
}
} catch (error) {
Toast.show('Error posting comment', 'error');
}
});
</script>
</body>
</html>