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