commit 852613586fc9913a63b9cc048084411dc542ed34 Author: Kalvin Carefour Johnny Date: Sat Aug 8 14:43:59 2026 +0800 first commit, mate! diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab31c7a --- /dev/null +++ b/.env.example @@ -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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a66d4d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f044c30 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b15347e --- /dev/null +++ b/CONTRIBUTING.md @@ -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. πŸ™ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1688974 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/INSTALL-COOLIFY.md b/INSTALL-COOLIFY.md new file mode 100644 index 0000000..08a3eff --- /dev/null +++ b/INSTALL-COOLIFY.md @@ -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 + 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` ✍️ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f2efc3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ca64dd3 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..93dba96 --- /dev/null +++ b/README.md @@ -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 +cd blogging-cms + +# Install dependencies +go mod tidy + +# Start MariaDB (Docker) +docker run -d \ + -e MYSQL_ROOT_PASSWORD=root \ + -e MYSQL_DATABASE=blogging_cms \ + -p 3306:3306 \ + mariadb:11.0 + +# Run application +go run main.go +``` + +### Building Docker Image + +```bash +docker build -t blogging-cms:latest . +``` + +### Testing + +```bash +# Unit tests (not yet implemented) +go test ./... + +# Integration tests with docker-compose +docker-compose -f docker-compose.test.yml up +``` + +## Markdown Syntax + +Supported Markdown features: + +- **Headers**: `# H1`, `## H2`, etc. +- **Bold**: `**text**` +- **Italic**: `*text*` +- **Code**: `` `inline` `` or code blocks with triple backticks +- **Lists**: `- item` or `1. item` +- **Links**: `[text](url)` +- **Images**: `![alt](url)` +- **Blockquotes**: `> quote` + +## Security Considerations + +- Passwords are hashed (consider using bcrypt in production) +- Session tokens are encrypted +- CSRF protection via sessions +- SQL injection protection via prepared statements +- XSS protection via template escaping +- Comments are moderated before publication + +### Hardening for Production + +1. **Use HTTPS** – Configure in reverse proxy (Caddy) +2. **Change SESSION_KEY** – Generate secure random key +3. **Use strong DB password** – At least 32 characters +4. **Enable backups** – MariaDB volume backups +5. **Rate limiting** – Add reverse proxy rules +6. **Content Security Policy** – Configure in Dockerfile + +## Performance + +- Lightweight Go binary (~10MB) +- Efficient MariaDB queries with indexing +- Static asset caching +- Lazy-loaded comments +- Paginated post listings + +## Troubleshooting + +### Database Connection Error +``` +Error: dial tcp mariadb:3306: connect: connection refused +``` +Solution: Ensure MariaDB container is running and healthy +```bash +docker-compose logs mariadb +``` + +### Posts Not Displaying +- Check `published=true` in database +- Verify database query with: `mysql -h mariadb -u cms_user -p` + +### Static Files 404 +- Ensure `static/` and `templates/` directories exist +- Check file permissions: `ls -la static/` + +## Contributing + +1. Fork the repository +2. Create feature branch: `git checkout -b feature/name` +3. Commit changes: `git commit -m "Add feature"` +4. Push: `git push origin feature/name` +5. Create Pull Request + +## License + +MIT License. See LICENSE file for details. + +## Acknowledgments + +- Material Design 3 guidelines +- Go standard library +- Gorilla toolkit +- Docker community + +## Author + +**Kalvin** – Civic technologist and digital sovereignty advocate. +- GitHub: [@kalvin0x8d0](https://github.com/kalvin0x8d0) +- Fediverse: [@kalvin@social.obulou.org](https://social.obulou.org/@kalvin) +- Personal site: [obulou.org](https://obulou.org) + +--- + +**Remember**: This is your space. Own your content. Self-host with care. ✍️ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cc8f18 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1acc6da --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b0782b1 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..38ee113 --- /dev/null +++ b/main.go @@ -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, ` + + +Blog +`) + fmt.Fprint(w, getEnv("SITE_URL", "http://localhost:8080")) + fmt.Fprint(w, ` +Latest posts +`) + + for rows.Next() { + var id, title, slug, excerpt string + var createdAt time.Time + rows.Scan(&id, &title, &slug, &excerpt, &createdAt) + + fmt.Fprintf(w, ` +%s +%s/post/%s +%s +%s + +`, title, getEnv("SITE_URL", "http://localhost:8080"), slug, excerpt, createdAt.Format(time.RFC1123Z)) + } + + fmt.Fprint(w, ` +`) +} + +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) + }) +} diff --git a/static/css/material.css b/static/css/material.css new file mode 100644 index 0000000..a27c6da --- /dev/null +++ b/static/css/material.css @@ -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; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..11f8d86 --- /dev/null +++ b/static/js/app.js @@ -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 = ` +
+

Create New Post

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ Supports Markdown syntax. Preview available. +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+ `; + + 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 = ` +
+

${title || 'Untitled'}

+
+ ${this.markdownToHtml(content)} +
+
+ `; + + const modal = new Modal('Preview', previewHtml); + modal.open(); + } + + markdownToHtml(markdown) { + let html = markdown + .replace(/^### (.*?)$/gm, '

$1

') + .replace(/^## (.*?)$/gm, '

$1

') + .replace(/^# (.*?)$/gm, '

$1

') + .replace(/\*\*(.*?)\*\*/gm, '$1') + .replace(/\*(.*?)\*/gm, '$1') + .replace(/`(.*?)`/gm, '$1') + .replace(/\n\n/gm, '

') + .replace(/^/gm, '

') + .replace(/$/gm, '

'); + + 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 = '
Loading posts...
'; + 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 = `
Error loading posts
`; + } + + this.loading = false; + } + + render(posts) { + if (posts.length === 0) { + this.container.innerHTML = '
No posts yet.
'; + return; + } + + let html = '
    '; + + posts.forEach(post => { + const date = new Date(post.created_at).toLocaleDateString(); + html += ` +
  • +
    +

    + ${post.title} +

    + + ${post.type === 'micro' ? 'πŸ’¬' : 'πŸ“'} ${post.type} + +
    + +

    ${post.excerpt || post.content.substring(0, 200)}

    + Read more β†’ +
  • + `; + }); + + html += '
'; + 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 = ` + + `; + 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 = '
'; + + posts.slice(0, 10).forEach(post => { + html += ` + +
${post.title}
+
${new Date(post.created_at).toLocaleDateString()}
+
+ `; + }); + + html += '
'; + + 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); diff --git a/templates/category.html b/templates/category.html new file mode 100644 index 0000000..dad8610 --- /dev/null +++ b/templates/category.html @@ -0,0 +1,74 @@ + + + + + + {{.category}} - Blog + + + +
+ +
+ +
+
+

{{.category}}

+

+ Posts in the {{.category}} category +

+
+ +
+ {{if .posts}} +
    + {{range .posts}} +
  • +
    +

    + {{.Title}} +

    + + {{if eq .Type "micro"}}πŸ’¬{{else}}πŸ“{{end}} {{.Type}} + +
    + +

    {{.Excerpt}}

    + Read more β†’ +
  • + {{end}} +
+ {{else}} +
+

No posts in this category yet.

+ ← Back to Home +
+ {{end}} +
+
+ +
+ + +
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d09979e --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,247 @@ + + + + + + Dashboard - Blog + + + +
+
+ + +
+
+ +
+
+ + + + +
+ +
+
+
+ + + + + + +
+
+
+ +
+ + +
+ + + + + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..9fa40d6 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,73 @@ + + + + + + Blog - Kalvin's Corner + + + + +
+ +
+ +
+
+

Welcome to the Blog

+

+ Essays, thoughts, and micro-posts on technology, digital sovereignty, and activism. +

+
+ + + +
+ +
+
+ +
+ + +
+ + + + diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..f58b8d4 --- /dev/null +++ b/templates/post.html @@ -0,0 +1,134 @@ + + + + + + {{.post.Title}} - Blog + + + +
+ +
+ +
+
+
+

{{.post.Title}}

+ + + + {{if .post.Tags}} +
+ {{range .post.Tags}} + {{.}} + {{end}} +
+ {{end}} +
+ +
+ {{.post.Content}} +
+
+ +
+ +
+

Comments

+ +
+
+ + +
+ +
+ +
+

+ Comments are moderated and will appear after approval. +

+
+
+ +
+ +
+
+ +
+ + +
+ + + + +