first commit, mate!
This commit is contained in:
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
@@ -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. 🙏
|
||||
@@ -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"]
|
||||
@@ -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` ✍️
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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**: ``
|
||||
- **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. ✍️
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>© 2024. Self-hosted with care.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>© 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>
|
||||
@@ -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>© 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>
|
||||
@@ -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>© 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>
|
||||
Reference in New Issue
Block a user