chore: clean up repo and fix docker compose configuration

- Remove outdated docs (COMPOSE.md, DOCKER.md, QUICKSTART.md, REBUILD_GUIDE.md)
- Remove build.sh, compose.production.yml, gamification-schema.sql, directus.yaml
- Simplify compose.yml for local dev (remove env var indirection)
- Add directus.yml schema snapshot and schema.sql from VPS
- Add schema:export and schema:import scripts to package.json
- Ignore .env files (vars set via compose environment)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 16:36:49 +01:00
parent ad83fb553a
commit 225b9d41f5
13 changed files with 2717 additions and 2673 deletions

3
.gitignore vendored
View File

@@ -3,5 +3,8 @@ dist/
target/ target/
pkg/ pkg/
.env
.env.* .env.*
.claude/

View File

@@ -1,424 +0,0 @@
# Docker Compose Guide
This guide explains the Docker Compose setup for sexy.pivoine.art with local development and production configurations.
## Architecture Overview
The application uses a **multi-file compose setup** with two configurations:
1. **`compose.yml`** - Base configuration for local development
2. **`compose.production.yml`** - Production overrides with Traefik integration
### Service Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ 🌐 Traefik Reverse Proxy (Production Only) │
│ ├─ HTTPS Termination │
│ ├─ Automatic Let's Encrypt │
│ └─ Routes traffic to frontend & Directus API │
├─────────────────────────────────────────────────────────────┤
│ 💄 Frontend (SvelteKit) │
│ ├─ Port 3000 (internal) │
│ ├─ Serves on https://sexy.pivoine.art │
│ └─ Proxies /api to Directus │
├─────────────────────────────────────────────────────────────┤
│ 🎭 Directus CMS │
│ ├─ Port 8055 (internal) │
│ ├─ Serves on https://sexy.pivoine.art/api │
│ ├─ Custom bundle extensions mounted │
│ └─ Uploads volume │
├─────────────────────────────────────────────────────────────┤
│ 🗄️ PostgreSQL (Local) / External (Production) │
│ └─ Database for Directus │
├─────────────────────────────────────────────────────────────┤
│ 💾 Redis (Local) / External (Production) │
│ └─ Cache & session storage │
└─────────────────────────────────────────────────────────────┘
```
## Local Development Setup
### Prerequisites
- Docker 20.10+
- Docker Compose 2.0+
### Quick Start
1. **Create environment file:**
```bash
cp .env.example .env
# Edit .env with your local settings (defaults work fine)
```
2. **Start all services:**
```bash
docker-compose up -d
```
3. **Access services:**
- Frontend: http://localhost:3000 (if enabled)
- Directus: http://localhost:8055
- Directus Admin: http://localhost:8055/admin
4. **View logs:**
```bash
docker-compose logs -f
```
5. **Stop services:**
```bash
docker-compose down
```
### Local Services
#### PostgreSQL
- **Image:** `postgres:16-alpine`
- **Port:** 5432 (internal only)
- **Volume:** `postgres-data`
- **Database:** `sexy`
#### Redis
- **Image:** `redis:7-alpine`
- **Port:** 6379 (internal only)
- **Volume:** `redis-data`
- **Persistence:** AOF enabled
#### Directus
- **Image:** `directus/directus:11`
- **Port:** 8055 (exposed)
- **Volumes:**
- `directus-uploads` - File uploads
- `./packages/bundle/dist` - Custom extensions
- **Features:**
- Auto-reload extensions
- WebSockets enabled
- CORS enabled for localhost
### Local Development Workflow
```bash
# Start infrastructure (Postgres, Redis, Directus)
docker-compose up -d
# Develop frontend locally with hot reload
cd packages/frontend
pnpm dev
# Build Directus bundle
pnpm --filter @sexy.pivoine.art/bundle build
# Restart Directus to load new bundle
docker-compose restart directus
```
## Production Deployment
### Prerequisites
- External PostgreSQL database
- External Redis instance
- Traefik reverse proxy configured
- External network: `compose_network`
### Setup
The production compose file now uses the `include` directive to automatically extend `compose.yml`, making deployment simpler.
1. **Create production environment file:**
```bash
cp .env.production.example .env.production
```
2. **Edit `.env.production` with your values:**
```bash
# Database (external)
CORE_DB_HOST=your-postgres-host
SEXY_DB_NAME=sexy_production
DB_USER=sexy
DB_PASSWORD=your-secure-password
# Redis (external)
CORE_REDIS_HOST=your-redis-host
# Directus
SEXY_DIRECTUS_SECRET=your-32-char-random-secret
ADMIN_PASSWORD=your-secure-admin-password
# Traefik
SEXY_TRAEFIK_HOST=sexy.pivoine.art
# Frontend
PUBLIC_API_URL=https://sexy.pivoine.art/api
PUBLIC_URL=https://sexy.pivoine.art
# Email (SMTP)
EMAIL_SMTP_HOST=smtp.your-provider.com
EMAIL_SMTP_USER=your-email@domain.com
EMAIL_SMTP_PASSWORD=your-smtp-password
```
3. **Deploy:**
```bash
# Simple deployment - compose.production.yml includes compose.yml automatically
docker-compose -f compose.production.yml --env-file .env.production up -d
# Or use the traditional multi-file approach (same result)
docker-compose -f compose.yml -f compose.production.yml --env-file .env.production up -d
```
### Production Services
#### Directus
- **Image:** `directus/directus:11` (configurable)
- **Network:** `compose_network` (external)
- **Volumes:**
- `/var/www/sexy.pivoine.art/uploads` - Persistent uploads
- `/var/www/sexy.pivoine.art/packages/bundle/dist` - Extensions
- **Traefik routing:**
- Domain: `sexy.pivoine.art/api`
- Strips `/api` prefix before forwarding
- HTTPS with auto-certificates
#### Frontend
- **Image:** `ghcr.io/valknarxxx/sexy:latest` (from GHCR)
- **Network:** `compose_network` (external)
- **Volume:** `/var/www/sexy.pivoine.art` - Application code
- **Traefik routing:**
- Domain: `sexy.pivoine.art`
- HTTPS with auto-certificates
### Traefik Integration
Both services are configured with Traefik labels for automatic routing:
**Frontend:**
- HTTP → HTTPS redirect
- Routes `sexy.pivoine.art` to port 3000
- Gzip compression enabled
**Directus API:**
- HTTP → HTTPS redirect
- Routes `sexy.pivoine.art/api` to port 8055
- Strips `/api` prefix
- Gzip compression enabled
### Production Commands
```bash
# Deploy/update (simplified - uses include)
docker-compose -f compose.production.yml --env-file .env.production up -d
# View logs
docker-compose -f compose.production.yml logs -f
# Restart specific service
docker-compose -f compose.production.yml restart frontend
# Stop all services
docker-compose -f compose.production.yml down
# Update images
docker-compose -f compose.production.yml pull
docker-compose -f compose.production.yml up -d
```
## Environment Variables
### Local Development (`.env`)
| Variable | Default | Description |
|----------|---------|-------------|
| `DB_DATABASE` | `sexy` | Database name |
| `DB_USER` | `sexy` | Database user |
| `DB_PASSWORD` | `sexy` | Database password |
| `DIRECTUS_SECRET` | - | Secret for Directus (min 32 chars) |
| `ADMIN_EMAIL` | `admin@sexy.pivoine.art` | Admin email |
| `ADMIN_PASSWORD` | `admin` | Admin password |
| `CORS_ORIGIN` | `http://localhost:3000` | CORS allowed origins |
See `.env.example` for full list.
### Production (`.env.production`)
| Variable | Description | Required |
|----------|-------------|----------|
| `CORE_DB_HOST` | External PostgreSQL host | ✅ |
| `SEXY_DB_NAME` | Database name | ✅ |
| `DB_PASSWORD` | Database password | ✅ |
| `CORE_REDIS_HOST` | External Redis host | ✅ |
| `SEXY_DIRECTUS_SECRET` | Directus secret key | ✅ |
| `SEXY_TRAEFIK_HOST` | Domain name | ✅ |
| `EMAIL_SMTP_HOST` | SMTP server | ✅ |
| `EMAIL_SMTP_PASSWORD` | SMTP password | ✅ |
| `SEXY_FRONTEND_PUBLIC_API_URL` | Frontend API URL | ✅ |
| `SEXY_FRONTEND_PUBLIC_URL` | Frontend public URL | ✅ |
See `.env.production.example` for full list.
**Note:** All frontend-specific variables are prefixed with `SEXY_FRONTEND_` for clarity.
## Volumes
### Local Development
- `postgres-data` - PostgreSQL database
- `redis-data` - Redis persistence
- `directus-uploads` - Uploaded files
### Production
- `/var/www/sexy.pivoine.art/uploads` - Directus uploads
- `/var/www/sexy.pivoine.art` - Application code (frontend)
## Networks
### Local: `sexy-network`
- Bridge network
- Internal communication only
- Directus exposed on 8055
### Production: `compose_network`
- External network (pre-existing)
- Connects to Traefik
- No exposed ports (Traefik handles routing)
## Health Checks
All services include health checks:
**PostgreSQL:**
- Command: `pg_isready`
- Interval: 10s
**Redis:**
- Command: `redis-cli ping`
- Interval: 10s
**Directus:**
- Endpoint: `/server/health`
- Interval: 30s
**Frontend:**
- HTTP GET: `localhost:3000`
- Interval: 30s
## Troubleshooting
### Local Development
**Problem:** Directus won't start
```bash
# Check logs
docker-compose logs directus
# Common issues:
# 1. Database not ready - wait for postgres to be healthy
# 2. Wrong secret - check DIRECTUS_SECRET is at least 32 chars
```
**Problem:** Can't connect to database
```bash
# Check if postgres is running
docker-compose ps postgres
# Verify health
docker-compose exec postgres pg_isready -U sexy
```
**Problem:** Extensions not loading
```bash
# Rebuild bundle
pnpm --filter @sexy.pivoine.art/bundle build
# Verify volume mount
docker-compose exec directus ls -la /directus/extensions/
# Restart Directus
docker-compose restart directus
```
### Production
**Problem:** Services not accessible via domain
```bash
# Check Traefik labels
docker inspect sexy_frontend | grep traefik
# Verify compose_network exists
docker network ls | grep compose_network
# Check Traefik is running
docker ps | grep traefik
```
**Problem:** Can't connect to external database
```bash
# Test connection from Directus container
docker-compose exec directus sh
apk add postgresql-client
psql -h $CORE_DB_HOST -U $DB_USER -d $SEXY_DB_NAME
```
**Problem:** Frontend can't reach Directus API
```bash
# Check Directus is accessible
curl https://sexy.pivoine.art/api/server/health
# Verify CORS settings
# PUBLIC_API_URL should match the public Directus URL
```
## Migration from Old Setup
If migrating from `docker-compose.production.yml`:
1. **Rename environment variables** according to `.env.production.example`
2. **Update command** to use both compose files
3. **Verify Traefik labels** match your setup
4. **Test** with `docker-compose config` to see merged configuration
```bash
# Validate configuration
docker-compose -f compose.yml -f compose.production.yml --env-file .env.production config
# Deploy
docker-compose -f compose.yml -f compose.production.yml --env-file .env.production up -d
```
## Best Practices
### Local Development
1. Use default credentials (they're fine for local)
2. Keep `EXTENSIONS_AUTO_RELOAD=true` for quick iteration
3. Run frontend via `pnpm dev` for hot reload
4. Restart Directus after bundle changes
### Production
1. Use strong passwords for database and admin
2. Set `EXTENSIONS_AUTO_RELOAD=false` for stability
3. Use GHCR images for frontend
4. Enable Gzip compression via Traefik
5. Monitor logs regularly
6. Keep backups of uploads and database
## See Also
- [DOCKER.md](DOCKER.md) - Docker image documentation
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide
- [CLAUDE.md](CLAUDE.md) - Development guide

374
DOCKER.md
View File

@@ -1,374 +0,0 @@
# Docker Deployment Guide
This guide covers building and deploying sexy.pivoine.art using Docker.
## Overview
The Dockerfile uses a multi-stage build process:
1. **Base stage**: Sets up Node.js and pnpm
2. **Builder stage**: Installs Rust, compiles WASM, builds all packages
3. **Runner stage**: Minimal production image with only runtime dependencies
## Prerequisites
- Docker 20.10+ with BuildKit support
- Docker Compose 2.0+ (optional, for orchestration)
## Building the Image
### Basic Build
```bash
docker build -t sexy.pivoine.art:latest .
```
### Build with Build Arguments
```bash
docker build \
--build-arg NODE_ENV=production \
-t sexy.pivoine.art:latest \
.
```
### Multi-platform Build (for ARM64 and AMD64)
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t sexy.pivoine.art:latest \
--push \
.
```
## Running the Container
### Run with Environment Variables
```bash
docker run -d \
--name sexy-pivoine-frontend \
-p 3000:3000 \
-e PUBLIC_API_URL=https://api.pivoine.art \
-e PUBLIC_URL=https://sexy.pivoine.art \
-e PUBLIC_UMAMI_ID=your-umami-id \
-e PUBLIC_UMAMI_SCRIPT=https://umami.pivoine.art/script.js \
sexy.pivoine.art:latest
```
### Run with Environment File
```bash
# Create .env.production from template
cp .env.production.example .env.production
# Edit .env.production with your values
nano .env.production
# Run container
docker run -d \
--name sexy-pivoine-frontend \
-p 3000:3000 \
--env-file .env.production \
sexy.pivoine.art:latest
```
## Docker Compose Deployment
### Using docker-compose.production.yml
```bash
# 1. Create environment file
cp .env.production.example .env.production
# 2. Edit environment variables
nano .env.production
# 3. Build and start
docker-compose -f docker-compose.production.yml up -d --build
# 4. View logs
docker-compose -f docker-compose.production.yml logs -f frontend
# 5. Stop services
docker-compose -f docker-compose.production.yml down
```
### Scale the Application
```bash
docker-compose -f docker-compose.production.yml up -d --scale frontend=3
```
## Environment Variables Reference
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `PUBLIC_API_URL` | Directus API backend URL | `https://api.pivoine.art` |
| `PUBLIC_URL` | Frontend application URL | `https://sexy.pivoine.art` |
### Optional Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `PUBLIC_UMAMI_ID` | Umami analytics tracking ID | `abc123def-456` |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL | `https://umami.pivoine.art/script.js` |
| `PORT` | Application port (inside container) | `3000` |
| `HOST` | Host binding | `0.0.0.0` |
| `NODE_ENV` | Node environment | `production` |
## Health Checks
The container includes a built-in health check that pings the HTTP server every 30 seconds:
```bash
# Check container health
docker inspect --format='{{.State.Health.Status}}' sexy-pivoine-frontend
# View health check logs
docker inspect --format='{{json .State.Health}}' sexy-pivoine-frontend | jq
```
## Logs and Debugging
### View Container Logs
```bash
# Follow logs
docker logs -f sexy-pivoine-frontend
# Last 100 lines
docker logs --tail 100 sexy-pivoine-frontend
# With timestamps
docker logs -f --timestamps sexy-pivoine-frontend
```
### Execute Commands in Running Container
```bash
# Open shell
docker exec -it sexy-pivoine-frontend sh
# Check Node.js version
docker exec sexy-pivoine-frontend node --version
# Check environment variables
docker exec sexy-pivoine-frontend env
```
### Debug Build Issues
```bash
# Build with no cache
docker build --no-cache -t sexy.pivoine.art:latest .
# Build specific stage for debugging
docker build --target builder -t sexy.pivoine.art:builder .
# Inspect builder stage
docker run -it --rm sexy.pivoine.art:builder sh
```
## Production Best Practices
### 1. Use Specific Tags
```bash
# Tag with version
docker build -t sexy.pivoine.art:1.0.0 .
docker tag sexy.pivoine.art:1.0.0 sexy.pivoine.art:latest
```
### 2. Image Scanning
```bash
# Scan for vulnerabilities (requires Docker Scout or Trivy)
docker scout cves sexy.pivoine.art:latest
# Or with Trivy
trivy image sexy.pivoine.art:latest
```
### 3. Resource Limits
```bash
docker run -d \
--name sexy-pivoine-frontend \
-p 3000:3000 \
--memory="2g" \
--cpus="2" \
--env-file .env.production \
sexy.pivoine.art:latest
```
### 4. Restart Policies
```bash
docker run -d \
--name sexy-pivoine-frontend \
--restart=unless-stopped \
-p 3000:3000 \
--env-file .env.production \
sexy.pivoine.art:latest
```
### 5. Use Docker Secrets (Docker Swarm)
```bash
# Create secrets
echo "your-db-password" | docker secret create db_password -
# Deploy with secrets
docker service create \
--name sexy-pivoine-frontend \
--secret db_password \
-p 3000:3000 \
sexy.pivoine.art:latest
```
## Optimization Tips
### Reduce Build Time
1. **Use BuildKit cache mounts** (already enabled in Dockerfile)
2. **Leverage layer caching** - structure Dockerfile to cache dependencies
3. **Use `.dockerignore`** - exclude unnecessary files from build context
### Reduce Image Size
Current optimizations:
- Multi-stage build (builder artifacts not in final image)
- Production-only dependencies (`pnpm install --prod`)
- Minimal base image (`node:20.19.1-slim`)
- Only necessary build artifacts copied to runner
Image size breakdown:
```bash
docker images sexy.pivoine.art:latest
```
## CI/CD Integration
### GitHub Actions (Automated)
This repository includes automated GitHub Actions workflows for building, scanning, and managing Docker images.
**Pre-configured workflows:**
- **Build & Push** (`.github/workflows/docker-build-push.yml`)
- Automatically builds and pushes to `ghcr.io/valknarxxx/sexy`
- Triggers on push to main/develop, version tags, and PRs
- Multi-platform builds (AMD64 + ARM64)
- Smart tagging: latest, branch names, semver, commit SHAs
- **Security Scan** (`.github/workflows/docker-scan.yml`)
- Daily vulnerability scans with Trivy
- Reports to GitHub Security tab
- Scans on every release
- **Cleanup** (`.github/workflows/cleanup-images.yml`)
- Weekly cleanup of old untagged images
- Keeps last 10 versions
**Using pre-built images:**
```bash
# Pull latest from GitHub Container Registry
docker pull ghcr.io/valknarxxx/sexy:latest
# Pull specific version
docker pull ghcr.io/valknarxxx/sexy:v1.0.0
# Run the image
docker run -d -p 3000:3000 --env-file .env.production ghcr.io/valknarxxx/sexy:latest
```
**Triggering builds:**
```bash
# Push to main → builds 'latest' tag
git push origin main
# Create version tag → builds semver tags
git tag v1.0.0 && git push origin v1.0.0
# Pull request → builds but doesn't push
```
See `.github/workflows/README.md` for detailed workflow documentation.
## Troubleshooting
### Build Fails at Rust Installation
**Problem**: Rust installation fails or times out
**Solution**:
- Check internet connectivity
- Use a Rust mirror if in restricted network
- Increase build timeout
### WASM Build Fails
**Problem**: `wasm-bindgen-cli` version mismatch
**Solution**:
```dockerfile
# In Dockerfile, pin wasm-bindgen-cli version
RUN cargo install wasm-bindgen-cli --version 0.2.103
```
### Container Exits Immediately
**Problem**: Container starts then exits
**Solution**: Check logs and verify:
```bash
docker logs sexy-pivoine-frontend
# Verify build output exists
docker run -it --rm sexy.pivoine.art:latest ls -la packages/frontend/build
```
### Port Already in Use
**Problem**: Port 3000 already bound
**Solution**:
```bash
# Use different host port
docker run -d -p 8080:3000 sexy.pivoine.art:latest
```
## Maintenance
### Clean Up
```bash
# Remove stopped containers
docker container prune
# Remove unused images
docker image prune -a
# Remove build cache
docker builder prune
# Complete cleanup (use with caution)
docker system prune -a --volumes
```
### Update Base Image
Regularly update the base Node.js image:
```bash
# Pull latest Node 20 LTS
docker pull node:20.19.1-slim
# Rebuild
docker build --pull -t sexy.pivoine.art:latest .
```

View File

@@ -1,330 +0,0 @@
# Quick Start Guide
Get sexy.pivoine.art running in under 5 minutes using pre-built Docker images.
## Prerequisites
- Docker 20.10+
- Docker Compose 2.0+ (optional)
## Option 1: Docker Run (Fastest)
### Step 1: Pull the Image
```bash
docker pull ghcr.io/valknarxxx/sexy:latest
```
### Step 2: Create Environment File
```bash
cat > .env.production << EOF
PUBLIC_API_URL=https://api.your-domain.com
PUBLIC_URL=https://your-domain.com
PUBLIC_UMAMI_ID=
PUBLIC_UMAMI_SCRIPT=
EOF
```
### Step 3: Run the Container
```bash
docker run -d \
--name sexy-pivoine \
-p 3000:3000 \
--env-file .env.production \
--restart unless-stopped \
ghcr.io/valknarxxx/sexy:latest
```
### Step 4: Verify
```bash
# Check if running
docker ps | grep sexy-pivoine
# Check logs
docker logs -f sexy-pivoine
# Test the application
curl http://localhost:3000
```
Your application is now running at `http://localhost:3000` 🎉
## Option 2: Docker Compose (Recommended)
### Step 1: Download docker-compose.production.yml
```bash
curl -O https://raw.githubusercontent.com/valknarxxx/sexy/main/docker-compose.production.yml
```
Or if you have the repository:
```bash
cd /path/to/sexy.pivoine.art
```
### Step 2: Create Environment File
```bash
cp .env.production.example .env.production
nano .env.production # Edit with your values
```
### Step 3: Start Services
```bash
docker-compose -f docker-compose.production.yml up -d
```
### Step 4: Monitor
```bash
# View logs
docker-compose -f docker-compose.production.yml logs -f
# Check status
docker-compose -f docker-compose.production.yml ps
```
Your application is now running at `http://localhost:3000` 🎉
## Accessing Private Images
If the image is in a private registry:
### Step 1: Create GitHub Personal Access Token
1. Go to https://github.com/settings/tokens
2. Click "Generate new token (classic)"
3. Select scope: `read:packages`
4. Generate and copy the token
### Step 2: Login to GitHub Container Registry
```bash
echo YOUR_GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
```
### Step 3: Pull and Run
Now you can pull private images:
```bash
docker pull ghcr.io/valknarxxx/sexy:latest
```
## Environment Variables
### Required
| Variable | Description | Example |
|----------|-------------|---------|
| `PUBLIC_API_URL` | Directus API endpoint | `https://api.pivoine.art` |
| `PUBLIC_URL` | Frontend URL | `https://sexy.pivoine.art` |
### Optional
| Variable | Description | Example |
|----------|-------------|---------|
| `PUBLIC_UMAMI_ID` | Analytics tracking ID | `abc-123-def` |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL | `https://umami.example.com/script.js` |
## Common Commands
### View Logs
```bash
# Follow logs (Docker Run)
docker logs -f sexy-pivoine
# Follow logs (Docker Compose)
docker-compose -f docker-compose.production.yml logs -f
```
### Restart Container
```bash
# Docker Run
docker restart sexy-pivoine
# Docker Compose
docker-compose -f docker-compose.production.yml restart
```
### Stop Container
```bash
# Docker Run
docker stop sexy-pivoine
# Docker Compose
docker-compose -f docker-compose.production.yml down
```
### Update to Latest Version
```bash
# Docker Run
docker pull ghcr.io/valknarxxx/sexy:latest
docker stop sexy-pivoine
docker rm sexy-pivoine
# Then re-run the docker run command from Step 3
# Docker Compose
docker-compose -f docker-compose.production.yml pull
docker-compose -f docker-compose.production.yml up -d
```
### Shell Access
```bash
# Docker Run
docker exec -it sexy-pivoine sh
# Docker Compose
docker-compose -f docker-compose.production.yml exec frontend sh
```
## Available Image Tags
| Tag | Description | Use Case |
|-----|-------------|----------|
| `latest` | Latest stable build from main | Production |
| `v1.0.0` | Specific version | Production (pinned) |
| `develop` | Latest from develop branch | Staging |
| `main-abc123` | Specific commit | Testing |
**Best Practice:** Use version tags in production for predictable deployments.
## Production Deployment
### 1. Use Version Tags
```bash
# Instead of :latest
docker pull ghcr.io/valknarxxx/sexy:v1.0.0
```
### 2. Add Resource Limits
```bash
docker run -d \
--name sexy-pivoine \
-p 3000:3000 \
--env-file .env.production \
--memory="2g" \
--cpus="2" \
--restart unless-stopped \
ghcr.io/valknarxxx/sexy:v1.0.0
```
### 3. Use a Reverse Proxy
Example with nginx:
```nginx
server {
listen 80;
server_name sexy.pivoine.art;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### 4. Enable HTTPS
Use Certbot or similar:
```bash
certbot --nginx -d sexy.pivoine.art
```
## Health Check
The container includes a built-in health check:
```bash
# Check container health
docker inspect --format='{{.State.Health.Status}}' sexy-pivoine
```
Possible statuses:
- `starting` - Container just started
- `healthy` - Application is responding
- `unhealthy` - Application is not responding
## Troubleshooting
### Container Exits Immediately
```bash
# Check logs
docker logs sexy-pivoine
# Common issues:
# - Missing environment variables
# - Port 3000 already in use
# - Invalid environment variable values
```
### Cannot Pull Image
```bash
# For private images, ensure you're logged in
docker login ghcr.io
# Check if image exists
docker pull ghcr.io/valknarxxx/sexy:latest
```
### Port Already in Use
```bash
# Use a different port
docker run -d -p 8080:3000 ghcr.io/valknarxxx/sexy:latest
# Or find what's using port 3000
lsof -i :3000
```
### Application Not Accessible
```bash
# Check if container is running
docker ps | grep sexy-pivoine
# Check logs
docker logs sexy-pivoine
# Verify port mapping
docker port sexy-pivoine
# Test from inside container
docker exec sexy-pivoine wget -O- http://localhost:3000
```
## Next Steps
- **Production setup:** See [DOCKER.md](DOCKER.md)
- **Development:** See [CLAUDE.md](CLAUDE.md)
- **CI/CD:** See [.github/workflows/README.md](.github/workflows/README.md)
## Support
- **Issues:** https://github.com/valknarxxx/sexy/issues
- **Discussions:** https://github.com/valknarxxx/sexy/discussions
- **Security:** Report privately via GitHub Security tab
## License
See [LICENSE](LICENSE) file for details.

View File

@@ -1,262 +0,0 @@
# 🔄 Rebuild Guide - When You Need to Rebuild the Image
## Why Rebuild?
SvelteKit's `PUBLIC_*` environment variables are **baked into the JavaScript** at build time. You need to rebuild when:
1. ✅ Changing `PUBLIC_API_URL`
2. ✅ Changing `PUBLIC_URL`
3. ❌ NOT needed for `PUBLIC_UMAMI_ID` or `PUBLIC_UMAMI_SCRIPT` (those are runtime)
4. ❌ NOT needed for Directus env vars (those are runtime)
## Quick Rebuild Process
### 1. Update Frontend Environment Variables
Edit the frontend `.env` file:
```bash
nano packages/frontend/.env
```
Set your production values:
```bash
PUBLIC_API_URL=https://sexy.pivoine.art/api
PUBLIC_URL=https://sexy.pivoine.art
# Note: PUBLIC_UMAMI_* can also be set at runtime in docker-compose
PUBLIC_UMAMI_ID=your-umami-id
PUBLIC_UMAMI_SCRIPT=https://umami.pivoine.art/script.js
```
### 2. Rebuild the Image
```bash
# From the project root
docker build -t ghcr.io/valknarxxx/sexy:latest -t sexy.pivoine.art:latest .
```
**Expected Time:** 30-45 minutes (first build), 10-15 minutes (cached rebuild)
### 3. Restart Services
```bash
# If using docker-compose
cd /home/valknar/Projects/docker-compose/sexy
docker compose down
docker compose up -d
# Or directly
docker stop sexy_frontend
docker rm sexy_frontend
docker compose up -d frontend
```
## Monitoring the Build
### Check Build Progress
```bash
# Watch build output
docker build -t ghcr.io/valknarxxx/sexy:latest .
# Build stages:
# 1. Base (~30s) - Node.js setup
# 2. Builder (~25-40min) - Rust + WASM + packages
# - Rust installation: ~2-3 min
# - wasm-bindgen-cli: ~10-15 min
# - WASM build: ~5-10 min
# - Package builds: ~5-10 min
# 3. Runner (~2min) - Final image assembly
```
### Verify Environment Variables in Built Image
```bash
# Check what PUBLIC_API_URL is baked in
docker run --rm ghcr.io/valknarxxx/sexy:latest sh -c \
"grep -r 'PUBLIC_API_URL' /home/node/app/packages/frontend/build/ | head -3"
# Should show: https://sexy.pivoine.art/api
```
## Push to GitHub Container Registry
After successful build:
```bash
# Login to GHCR (first time only)
echo $GITHUB_TOKEN | docker login ghcr.io -u valknarxxx --password-stdin
# Push the image
docker push ghcr.io/valknarxxx/sexy:latest
```
## Alternative: Build Arguments (Future Enhancement)
To avoid rebuilding for every env change, consider adding build arguments:
```dockerfile
# In Dockerfile, before building frontend:
ARG PUBLIC_API_URL=https://sexy.pivoine.art/api
ARG PUBLIC_URL=https://sexy.pivoine.art
ARG PUBLIC_UMAMI_ID=
# Create .env.production dynamically
RUN echo "PUBLIC_API_URL=${PUBLIC_API_URL}" > packages/frontend/.env.production && \
echo "PUBLIC_URL=${PUBLIC_URL}" >> packages/frontend/.env.production && \
echo "PUBLIC_UMAMI_ID=${PUBLIC_UMAMI_ID}" >> packages/frontend/.env.production
```
Then build with:
```bash
docker build \
--build-arg PUBLIC_API_URL=https://sexy.pivoine.art/api \
--build-arg PUBLIC_URL=https://sexy.pivoine.art \
-t ghcr.io/valknarxxx/sexy:latest .
```
## Troubleshooting
### Build Fails at Rust Installation
```bash
# Check network connectivity
ping -c 3 sh.rustup.rs
# Build with verbose output
docker build --progress=plain -t ghcr.io/valknarxxx/sexy:latest .
```
### Build Fails at WASM
```bash
# Check if wasm-bindgen-cli matches package.json version
docker run --rm rust:latest cargo install wasm-bindgen-cli --version 0.2.103
```
### Frontend Still Shows Wrong URL
```bash
# Verify .env file is correct
cat packages/frontend/.env
# Check if old image is cached
docker images | grep sexy
docker rmi ghcr.io/valknarxxx/sexy:old-tag
# Force rebuild without cache
docker build --no-cache -t ghcr.io/valknarxxx/sexy:latest .
```
### Container Starts But Can't Connect to API
1. Check Traefik routing:
```bash
docker logs traefik | grep sexy
```
2. Check if Directus is accessible:
```bash
curl -I https://sexy.pivoine.art/api/server/health
```
3. Check frontend logs:
```bash
docker logs sexy_frontend
```
## Development vs Production
### Development (Local)
- Use `pnpm dev` for hot reload
- No rebuild needed for code changes
- Env vars from `.env` or shell
### Production (Docker)
- Rebuild required for PUBLIC_* changes
- Changes baked into JavaScript
- Env vars from `packages/frontend/.env`
## Optimization Tips
### Speed Up Rebuilds
1. **Use BuildKit cache:**
```bash
export DOCKER_BUILDKIT=1
docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t ghcr.io/valknarxxx/sexy:latest .
```
2. **Multi-stage caching:**
- Dockerfile already optimized with multi-stage build
- Dependencies cached separately from code
3. **Parallel builds:**
```bash
# Build with more CPU cores
docker build --cpus 4 -t ghcr.io/valknarxxx/sexy:latest .
```
### Reduce Image Size
Current optimizations:
- ✅ Multi-stage build
- ✅ Production dependencies only
- ✅ Minimal base image
- ✅ No dev tools in final image
Expected sizes:
- Base: ~100MB
- Builder: ~2-3GB (not shipped)
- Runner: ~300-500MB (final)
## Automation
### GitHub Actions (Already Set Up)
The `.github/workflows/docker-build-push.yml` automatically:
1. Builds on push to main
2. Creates version tags
3. Pushes to GHCR
4. Caches layers for faster builds
**Trigger a rebuild:**
```bash
git tag v1.0.1
git push origin v1.0.1
```
### Local Build Script
Use the provided `build.sh`:
```bash
./build.sh -t v1.0.0 -p
```
## When NOT to Rebuild
You DON'T need to rebuild for:
- ❌ Directus configuration changes
- ❌ Database credentials
- ❌ Redis settings
- ❌ SMTP settings
- ❌ Session cookie settings
- ❌ Traefik labels
These are runtime environment variables and can be changed in docker-compose.
## Summary
| Change | Rebuild Needed | How to Apply |
|--------|----------------|--------------|
| `PUBLIC_API_URL` | ✅ Yes | Rebuild image |
| `PUBLIC_URL` | ✅ Yes | Rebuild image |
| `PUBLIC_UMAMI_*` | ❌ No | Restart container |
| `SEXY_DIRECTUS_*` | ❌ No | Restart container |
| `DB_*` | ❌ No | Restart container |
| `EMAIL_*` | ❌ No | Restart container |
| Traefik labels | ❌ No | Restart container |
---
**Remember:** The key difference is **build-time** (compiled into JS) vs **runtime** (read from environment).

130
build.sh
View File

@@ -1,130 +0,0 @@
#!/bin/bash
# Build script for sexy.pivoine.art Docker image
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Default values
IMAGE_NAME="sexy.pivoine.art"
TAG="latest"
PUSH=false
PLATFORM=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
-p|--push)
PUSH=true
shift
;;
--platform)
PLATFORM="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -t, --tag TAG Set image tag (default: latest)"
echo " -n, --name NAME Set image name (default: sexy.pivoine.art)"
echo " -p, --push Push image after build"
echo " --platform PLATFORM Build for specific platform (e.g., linux/amd64,linux/arm64)"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Build with defaults"
echo " $0 -t v1.0.0 # Build with version tag"
echo " $0 --platform linux/amd64,linux/arm64 -p # Multi-platform build and push"
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
FULL_IMAGE="${IMAGE_NAME}:${TAG}"
echo -e "${GREEN}=== Building Docker Image ===${NC}"
echo "Image: ${FULL_IMAGE}"
echo "Platform: ${PLATFORM:-default}"
echo ""
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}Error: Docker is not running${NC}"
exit 1
fi
# Build command
BUILD_CMD="docker build"
if [ -n "$PLATFORM" ]; then
# Multi-platform build requires buildx
echo -e "${YELLOW}Using buildx for multi-platform build${NC}"
BUILD_CMD="docker buildx build --platform ${PLATFORM}"
if [ "$PUSH" = true ]; then
BUILD_CMD="${BUILD_CMD} --push"
fi
else
# Regular build
if [ "$PUSH" = true ]; then
echo -e "${YELLOW}Note: --push only works with multi-platform builds. Use 'docker push' after build.${NC}"
fi
fi
# Execute build
echo -e "${GREEN}Building...${NC}"
$BUILD_CMD -t "${FULL_IMAGE}" .
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Build successful!${NC}"
echo "Image: ${FULL_IMAGE}"
# Show image size
if [ -z "$PLATFORM" ]; then
SIZE=$(docker images "${FULL_IMAGE}" --format "{{.Size}}")
echo "Size: ${SIZE}"
fi
# Push if requested and not multi-platform
if [ "$PUSH" = true ] && [ -z "$PLATFORM" ]; then
echo -e "${GREEN}Pushing image...${NC}"
docker push "${FULL_IMAGE}"
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Push successful!${NC}"
else
echo -e "${RED}✗ Push failed${NC}"
exit 1
fi
fi
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo "1. Run locally:"
echo " docker run -d -p 3000:3000 --env-file .env.production ${FULL_IMAGE}"
echo ""
echo "2. Run with docker-compose:"
echo " docker-compose -f docker-compose.production.yml up -d"
echo ""
echo "3. View logs:"
echo " docker logs -f <container-name>"
else
echo -e "${RED}✗ Build failed${NC}"
exit 1
fi

View File

@@ -1,128 +0,0 @@
include:
- compose.yml
# Production compose file - extends base compose.yml
# Usage: docker-compose -f compose.production.yml up -d
networks:
compose_network:
external: true
name: compose_network
services:
# Disable local postgres for production (use external DB)
postgres:
deploy:
replicas: 0
# Disable local redis for production (use external Redis)
redis:
deploy:
replicas: 0
# Override Directus for production
directus:
networks:
- compose_network
ports: [] # Remove exposed ports, use Traefik instead
# Override volumes for production paths
volumes:
- ${SEXY_DIRECTUS_UPLOADS:-./uploads}:/directus/uploads
- ${SEXY_DIRECTUS_BUNDLE:-./packages/bundle/dist}:/directus/extensions/sexy.pivoine.art
# Override environment for production settings
environment:
# Database (external)
DB_HOST: ${CORE_DB_HOST}
DB_PORT: ${CORE_DB_PORT:-5432}
DB_DATABASE: ${SEXY_DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
# General
SECRET: ${SEXY_DIRECTUS_SECRET}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
PUBLIC_URL: ${SEXY_PUBLIC_URL}
# Cache (external Redis)
REDIS: redis://${CORE_REDIS_HOST}:${CORE_REDIS_PORT:-6379}
# CORS
CORS_ORIGIN: ${SEXY_CORS_ORIGIN}
# Security (production settings)
SESSION_COOKIE_SECURE: ${SEXY_SESSION_COOKIE_SECURE:-true}
SESSION_COOKIE_SAME_SITE: ${SEXY_SESSION_COOKIE_SAME_SITE:-strict}
SESSION_COOKIE_DOMAIN: ${SEXY_SESSION_COOKIE_DOMAIN}
# Extensions
EXTENSIONS_AUTO_RELOAD: ${SEXY_EXTENSIONS_AUTO_RELOAD:-false}
# Email (production SMTP)
EMAIL_TRANSPORT: ${EMAIL_TRANSPORT:-smtp}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST}
EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-587}
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER}
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
# User URLs
USER_REGISTER_URL_ALLOW_LIST: ${SEXY_USER_REGISTER_URL_ALLOW_LIST}
PASSWORD_RESET_URL_ALLOW_LIST: ${SEXY_PASSWORD_RESET_URL_ALLOW_LIST}
# Remove local dependencies
depends_on: []
labels:
# Traefik labels for reverse proxy
- 'traefik.enable=${SEXY_TRAEFIK_ENABLED:-true}'
- 'traefik.http.middlewares.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-redirect-web-secure.redirectscheme.scheme=https'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web.middlewares=${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-redirect-web-secure'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web.rule=Host(`${SEXY_TRAEFIK_HOST}`) && PathPrefix(`/api`)'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web.entrypoints=web'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure.rule=Host(`${SEXY_TRAEFIK_HOST}`) && PathPrefix(`/api`)'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure.tls.certresolver=resolver'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure.entrypoints=web-secure'
- 'traefik.http.middlewares.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure-compress.compress=true'
- 'traefik.http.middlewares.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-strip.stripprefix.prefixes=/api'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure.middlewares=${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-strip,${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure-compress'
- 'traefik.http.services.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-api-web-secure.loadbalancer.server.port=8055'
- 'traefik.docker.network=compose_network'
# Override Frontend for production
frontend:
networks:
- compose_network
ports: [] # Remove exposed ports, use Traefik instead
# Override environment for production
environment:
NODE_ENV: production
PUBLIC_API_URL: ${SEXY_FRONTEND_PUBLIC_API_URL}
PUBLIC_URL: ${SEXY_FRONTEND_PUBLIC_URL}
PUBLIC_UMAMI_ID: ${SEXY_FRONTEND_PUBLIC_UMAMI_ID:-}
PUBLIC_UMAMI_SCRIPT: ${SEXY_FRONTEND_PUBLIC_UMAMI_SCRIPT:-}
# Override volume for production path
volumes:
- ${SEXY_FRONTEND_PATH:-/var/www/sexy.pivoine.art}:/home/node/app
# Remove local dependency
depends_on: []
labels:
# Traefik labels for reverse proxy
- 'traefik.enable=${SEXY_TRAEFIK_ENABLED:-true}'
- 'traefik.http.middlewares.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-redirect-web-secure.redirectscheme.scheme=https'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web.middlewares=${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-redirect-web-secure'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web.rule=Host(`${SEXY_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web.entrypoints=web'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure.rule=Host(`${SEXY_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure.tls.certresolver=resolver'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure.entrypoints=web-secure'
- 'traefik.http.middlewares.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure-compress.compress=true'
- 'traefik.http.routers.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure.middlewares=${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure-compress'
- 'traefik.http.services.${SEXY_COMPOSE_PROJECT_NAME:-sexy}-frontend-web-secure.loadbalancer.server.port=3000'
- 'traefik.docker.network=compose_network'

View File

@@ -1,112 +1,71 @@
name: sexy
services: services:
# PostgreSQL Database (local only)
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ${SEXY_COMPOSE_PROJECT_NAME:-sexy}_postgres container_name: sexy_postgres
restart: unless-stopped restart: unless-stopped
networks:
- sexy-network
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: ${DB_DATABASE:-sexy} POSTGRES_DB: sexy
POSTGRES_USER: ${DB_USER:-sexy} POSTGRES_USER: sexy
POSTGRES_PASSWORD: ${DB_PASSWORD:-sexy} POSTGRES_PASSWORD: sexy
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-sexy}"] test: ["CMD-SHELL", "pg_isready -U sexy"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Redis Cache (local only)
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: ${SEXY_COMPOSE_PROJECT_NAME:-sexy}_redis container_name: sexy_redis
restart: unless-stopped restart: unless-stopped
networks:
- sexy-network
volumes: volumes:
- redis-data:/data - redis_data:/data
command: redis-server --appendonly yes command: redis-server --appendonly yes
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Directus CMS
directus: directus:
image: ${SEXY_DIRECTUS_IMAGE:-directus/directus:11} image: directus/directus:11
container_name: ${SEXY_COMPOSE_PROJECT_NAME:-sexy}_api container_name: sexy_directus
restart: unless-stopped restart: unless-stopped
networks:
- sexy-network
ports: ports:
- "8055:8055" - "8055:8055"
volumes: volumes:
- directus-uploads:/directus/uploads - directus_uploads:/directus/uploads
- ${SEXY_DIRECTUS_BUNDLE:-./packages/bundle}:/directus/extensions/sexy.pivoine.art - ./packages/bundle:/directus/extensions/sexy.pivoine.art
environment: environment:
# Database
DB_CLIENT: pg DB_CLIENT: pg
DB_HOST: ${CORE_DB_HOST:-postgres} DB_HOST: sexy_postgres
DB_PORT: ${CORE_DB_PORT:-5432} DB_PORT: 5432
DB_DATABASE: ${SEXY_DB_NAME:-sexy} DB_DATABASE: sexy
DB_USER: ${DB_USER:-sexy} DB_USER: sexy
DB_PASSWORD: ${DB_PASSWORD:-sexy} DB_PASSWORD: sexy
ADMIN_EMAIL: admin@sexy
# General ADMIN_PASSWORD: admin
SECRET: ${SEXY_DIRECTUS_SECRET:-replace-with-random-secret-min-32-chars} PUBLIC_URL: http://localhost:3000/api
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@sexy.pivoine.art} CACHE_ENABLED: true
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} CACHE_AUTO_PURGE: true
PUBLIC_URL: ${SEXY_PUBLIC_URL:-http://localhost:8055}
# Cache
CACHE_ENABLED: ${SEXY_CACHE_ENABLED:-true}
CACHE_AUTO_PURGE: ${SEXY_CACHE_AUTO_PURGE:-true}
CACHE_STORE: redis CACHE_STORE: redis
REDIS: redis://${CORE_REDIS_HOST:-redis}:${CORE_REDIS_PORT:-6379} REDIS: redis://sexy_redis:6379
CORS_ENABLED: true
# CORS CORS_ORIGIN: http://localhost:3000
CORS_ENABLED: ${SEXY_CORS_ENABLED:-true} SESSION_COOKIE_SECURE: false
CORS_ORIGIN: ${SEXY_CORS_ORIGIN:-http://localhost:3000} SESSION_COOKIE_SAME_SITE: lax
SESSION_COOKIE_DOMAIN: localhost
# Security EXTENSIONS_PATH: /directus/extensions
SESSION_COOKIE_SECURE: ${SEXY_SESSION_COOKIE_SECURE:-false} EXTENSIONS_AUTO_RELOAD: true
SESSION_COOKIE_SAME_SITE: ${SEXY_SESSION_COOKIE_SAME_SITE:-lax} WEBSOCKETS_ENABLED: true
SESSION_COOKIE_DOMAIN: ${SEXY_SESSION_COOKIE_DOMAIN:-localhost} USER_REGISTER_URL_ALLOW_LIST: http://localhost:3000
PASSWORD_RESET_URL_ALLOW_LIST: http://localhost:3000
# Extensions TZ: Europe/Amsterdam
EXTENSIONS_PATH: ${SEXY_EXTENSIONS_PATH:-/directus/extensions}
EXTENSIONS_AUTO_RELOAD: ${SEXY_EXTENSIONS_AUTO_RELOAD:-true}
# WebSockets
WEBSOCKETS_ENABLED: ${SEXY_WEBSOCKETS_ENABLED:-true}
# Email (optional for local dev)
EMAIL_TRANSPORT: ${EMAIL_TRANSPORT:-sendmail}
EMAIL_FROM: ${EMAIL_FROM:-noreply@sexy.pivoine.art}
EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST:-}
EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-587}
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
# User Registration & Password Reset URLs
USER_REGISTER_URL_ALLOW_LIST: ${SEXY_USER_REGISTER_URL_ALLOW_LIST:-http://localhost:3000}
PASSWORD_RESET_URL_ALLOW_LIST: ${SEXY_PASSWORD_RESET_URL_ALLOW_LIST:-http://localhost:3000}
# Content Security Policy
CONTENT_SECURITY_POLICY_DIRECTIVES__FRAME_SRC: ${SEXY_CONTENT_SECURITY_POLICY_DIRECTIVES__FRAME_SRC:-}
# Timezone
TZ: ${TIMEZONE:-Europe/Amsterdam}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8055/server/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8055/server/health"]
interval: 30s interval: 30s
@@ -114,66 +73,10 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# Frontend (local development - optional, usually run via pnpm dev)
frontend:
image: ${SEXY_FRONTEND_IMAGE:-ghcr.io/valknarxxx/sexy:latest}
container_name: ${SEXY_COMPOSE_PROJECT_NAME:-sexy}_frontend
restart: unless-stopped
user: node
working_dir: /home/node/app/packages/frontend
networks:
- sexy-network
ports:
- "3000:3000"
environment:
# Node
NODE_ENV: ${NODE_ENV:-development}
PORT: 3000
HOST: 0.0.0.0
# Public environment variables
PUBLIC_API_URL: ${SEXY_FRONTEND_PUBLIC_API_URL:-http://localhost:8055}
PUBLIC_URL: ${SEXY_FRONTEND_PUBLIC_URL:-http://localhost:3000}
PUBLIC_UMAMI_ID: ${SEXY_FRONTEND_PUBLIC_UMAMI_ID:-}
PUBLIC_UMAMI_SCRIPT: ${SEXY_FRONTEND_PUBLIC_UMAMI_SCRIPT:-}
# Timezone
TZ: ${TIMEZONE:-Europe/Amsterdam}
volumes:
- ${SEXY_FRONTEND_PATH:-./}:/home/node/app
command: ["node", "build/index.js"]
depends_on:
- directus
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 3s
retries: 3
start_period: 40s
# Uncomment to run frontend in development mode with live reload
# build:
# context: .
# dockerfile: Dockerfile
# volumes:
# - ./packages/frontend:/home/node/app/packages/frontend
# - /home/node/app/packages/frontend/node_modules
# environment:
# NODE_ENV: development
networks:
sexy-network:
driver: bridge
name: ${SEXY_COMPOSE_PROJECT_NAME:-sexy}_network
volumes: volumes:
directus-uploads: directus_uploads:
driver: local driver: local
postgres-data: postgres_data:
driver: local driver: local
redis-data: redis_data:
driver: local driver: local

View File

@@ -102,34 +102,6 @@ collections:
versioning: false versioning: false
schema: schema:
name: sexy_videos_directus_users name: sexy_videos_directus_users
- collection: sexy_recordings
meta:
accountability: all
archive_app_filter: true
archive_field: status
archive_value: archived
collapse: open
collection: sexy_recordings
color: null
display_template: null
group: null
hidden: false
icon: fiber_manual_record
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations:
- language: en-US
plural: Recordings
singular: Recording
translation: Sexy Recordings
unarchive_value: draft
versioning: false
schema:
name: sexy_recordings
fields: fields:
- collection: directus_users - collection: directus_users
field: website field: website
@@ -206,7 +178,7 @@ fields:
max_length: 255 max_length: 255
numeric_precision: null numeric_precision: null
numeric_scale: null numeric_scale: null
is_nullable: true is_nullable: false
is_unique: true is_unique: true
is_indexed: true is_indexed: true
is_primary_key: false is_primary_key: false
@@ -1908,639 +1880,6 @@ fields:
has_auto_increment: false has_auto_increment: false
foreign_key_table: directus_users foreign_key_table: directus_users
foreign_key_column: id foreign_key_column: id
- collection: sexy_recordings
field: id
type: uuid
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: input
note: null
options: null
readonly: true
required: false
sort: 1
special:
- uuid
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: sexy_recordings
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: status
type: string
meta:
collection: sexy_recordings
conditions: null
display: labels
display_options:
choices:
- background: var(--theme--primary-background)
color: var(--theme--primary)
foreground: var(--theme--primary)
text: $t:published
value: published
- background: var(--theme--background-normal)
color: var(--theme--foreground)
foreground: var(--theme--foreground)
text: $t:draft
value: draft
- background: var(--theme--warning-background)
color: var(--theme--warning)
foreground: var(--theme--warning)
text: $t:archived
value: archived
showAsDot: true
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- color: var(--theme--primary)
text: $t:published
value: published
- color: var(--theme--foreground)
text: $t:draft
value: draft
- color: var(--theme--warning)
text: $t:archived
value: archived
readonly: false
required: false
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: status
table: sexy_recordings
data_type: character varying
default_value: draft
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: user_created
type: uuid
meta:
collection: sexy_recordings
conditions: null
display: user
display_options: null
field: user_created
group: null
hidden: true
interface: select-dropdown-m2o
note: null
options:
template: '{{avatar}} {{first_name}} {{last_name}}'
readonly: true
required: false
sort: 3
special:
- user-created
translations: null
validation: null
validation_message: null
width: half
schema:
name: user_created
table: sexy_recordings
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: directus_users
foreign_key_column: id
- collection: sexy_recordings
field: date_created
type: timestamp
meta:
collection: sexy_recordings
conditions: null
display: datetime
display_options:
relative: true
field: date_created
group: null
hidden: true
interface: datetime
note: null
options: null
readonly: true
required: false
sort: 4
special:
- date-created
translations: null
validation: null
validation_message: null
width: half
schema:
name: date_created
table: sexy_recordings
data_type: timestamp with time zone
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: date_updated
type: timestamp
meta:
collection: sexy_recordings
conditions: null
display: datetime
display_options:
relative: true
field: date_updated
group: null
hidden: true
interface: datetime
note: null
options: null
readonly: true
required: false
sort: 5
special:
- date-updated
translations: null
validation: null
validation_message: null
width: half
schema:
name: date_updated
table: sexy_recordings
data_type: timestamp with time zone
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: title
type: string
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: title
group: null
hidden: false
interface: input
note: null
options: null
readonly: false
required: true
sort: 6
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: title
table: sexy_recordings
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: description
type: text
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: description
group: null
hidden: false
interface: input-multiline
note: null
options:
trim: true
readonly: false
required: false
sort: 7
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: description
table: sexy_recordings
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: slug
type: string
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: slug
group: null
hidden: false
interface: input
note: null
options:
slug: true
trim: true
readonly: false
required: true
sort: 8
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: slug
table: sexy_recordings
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: true
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: duration
type: float
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: duration
group: null
hidden: false
interface: input
note: Duration in milliseconds
options: null
readonly: false
required: true
sort: 9
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: duration
table: sexy_recordings
data_type: double precision
default_value: null
max_length: null
numeric_precision: 53
numeric_scale: null
is_nullable: false
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: events
type: json
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: events
group: null
hidden: false
interface: input-code
note: Array of recorded events with timestamps
options:
language: json
readonly: false
required: true
sort: 10
special:
- cast-json
translations: null
validation: null
validation_message: null
width: full
schema:
name: events
table: sexy_recordings
data_type: json
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: device_info
type: json
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: device_info
group: null
hidden: false
interface: input-code
note: Array of device metadata
options:
language: json
readonly: false
required: true
sort: 11
special:
- cast-json
translations: null
validation: null
validation_message: null
width: full
schema:
name: device_info
table: sexy_recordings
data_type: json
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: tags
type: json
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: tags
group: null
hidden: false
interface: tags
note: null
options: null
readonly: false
required: false
sort: 12
special:
- cast-json
translations: null
validation: null
validation_message: null
width: full
schema:
name: tags
table: sexy_recordings
data_type: json
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: linked_video
type: uuid
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: linked_video
group: null
hidden: false
interface: select-dropdown-m2o
note: null
options:
enableLink: true
readonly: false
required: false
sort: 13
special:
- m2o
translations: null
validation: null
validation_message: null
width: full
schema:
name: linked_video
table: sexy_recordings
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: sexy_videos
foreign_key_column: id
- collection: sexy_recordings
field: featured
type: boolean
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: featured
group: null
hidden: false
interface: boolean
note: null
options:
label: Featured
readonly: false
required: false
sort: 14
special:
- cast-boolean
translations: null
validation: null
validation_message: null
width: full
schema:
name: featured
table: sexy_recordings
data_type: boolean
default_value: false
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: sexy_recordings
field: public
type: boolean
meta:
collection: sexy_recordings
conditions: null
display: null
display_options: null
field: public
group: null
hidden: false
interface: boolean
note: null
options:
label: Public
readonly: false
required: false
sort: 15
special:
- cast-boolean
translations: null
validation: null
validation_message: null
width: full
schema:
name: public
table: sexy_recordings
data_type: boolean
default_value: false
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
relations: relations:
- collection: directus_users - collection: directus_users
field: banner field: banner
@@ -2773,45 +2112,3 @@ relations:
constraint_name: sexy_videos_directus_users_sexy_videos_id_foreign constraint_name: sexy_videos_directus_users_sexy_videos_id_foreign
on_update: NO ACTION on_update: NO ACTION
on_delete: SET NULL on_delete: SET NULL
- collection: sexy_recordings
field: user_created
related_collection: directus_users
meta:
junction_field: null
many_collection: sexy_recordings
many_field: user_created
one_allowed_collections: null
one_collection: directus_users
one_collection_field: null
one_deselect_action: nullify
one_field: null
sort_field: null
schema:
table: sexy_recordings
column: user_created
foreign_key_table: directus_users
foreign_key_column: id
constraint_name: sexy_recordings_user_created_foreign
on_update: NO ACTION
on_delete: NO ACTION
- collection: sexy_recordings
field: linked_video
related_collection: sexy_videos
meta:
junction_field: null
many_collection: sexy_recordings
many_field: linked_video
one_allowed_collections: null
one_collection: sexy_videos
one_collection_field: null
one_deselect_action: nullify
one_field: null
sort_field: null
schema:
table: sexy_recordings
column: linked_video
foreign_key_table: sexy_videos
foreign_key_column: id
constraint_name: sexy_recordings_linked_video_foreign
on_update: NO ACTION
on_delete: SET NULL

View File

@@ -1,177 +0,0 @@
-- Gamification System Schema for Sexy Recordings Platform
-- Created: 2025-10-28
-- Description: Recording-focused gamification with time-weighted scoring
-- ====================
-- Table: sexy_recording_plays
-- ====================
-- Tracks when users play recordings (similar to video plays)
CREATE TABLE IF NOT EXISTS sexy_recording_plays (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
recording_id UUID NOT NULL REFERENCES sexy_recordings(id) ON DELETE CASCADE,
duration_played INTEGER, -- Duration played in milliseconds
completed BOOLEAN DEFAULT FALSE, -- True if >= 90% watched
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
date_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recording_plays_user ON sexy_recording_plays(user_id);
CREATE INDEX IF NOT EXISTS idx_recording_plays_recording ON sexy_recording_plays(recording_id);
CREATE INDEX IF NOT EXISTS idx_recording_plays_date ON sexy_recording_plays(date_created);
COMMENT ON TABLE sexy_recording_plays IS 'Tracks user playback of recordings for analytics and gamification';
COMMENT ON COLUMN sexy_recording_plays.completed IS 'True if user watched at least 90% of the recording';
-- ====================
-- Table: sexy_user_points
-- ====================
-- Tracks individual point-earning actions with timestamps for time-weighted scoring
CREATE TABLE IF NOT EXISTS sexy_user_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL, -- e.g., "RECORDING_CREATE", "RECORDING_PLAY", "COMMENT_CREATE"
points INTEGER NOT NULL, -- Raw points earned
recording_id UUID REFERENCES sexy_recordings(id) ON DELETE SET NULL, -- Optional reference
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_points_user ON sexy_user_points(user_id);
CREATE INDEX IF NOT EXISTS idx_user_points_date ON sexy_user_points(date_created);
CREATE INDEX IF NOT EXISTS idx_user_points_action ON sexy_user_points(action);
COMMENT ON TABLE sexy_user_points IS 'Individual point-earning actions for gamification system';
COMMENT ON COLUMN sexy_user_points.action IS 'Type of action: RECORDING_CREATE, RECORDING_PLAY, RECORDING_COMPLETE, COMMENT_CREATE, RECORDING_FEATURED';
COMMENT ON COLUMN sexy_user_points.points IS 'Raw points before time-weighted decay calculation';
-- ====================
-- Table: sexy_achievements
-- ====================
-- Predefined achievement definitions
CREATE TABLE IF NOT EXISTS sexy_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL, -- Unique identifier (e.g., "first_recording", "recording_100")
name VARCHAR(255) NOT NULL, -- Display name
description TEXT, -- Achievement description
icon VARCHAR(255), -- Icon identifier or emoji
category VARCHAR(50) NOT NULL, -- e.g., "recordings", "playback", "social", "special"
required_count INTEGER, -- Number of actions needed to unlock
points_reward INTEGER DEFAULT 0, -- Bonus points awarded upon unlock
sort INTEGER DEFAULT 0, -- Display order
status VARCHAR(20) DEFAULT 'published' -- published, draft, archived
);
CREATE INDEX IF NOT EXISTS idx_achievements_category ON sexy_achievements(category);
CREATE INDEX IF NOT EXISTS idx_achievements_code ON sexy_achievements(code);
COMMENT ON TABLE sexy_achievements IS 'Predefined achievement definitions for gamification';
COMMENT ON COLUMN sexy_achievements.code IS 'Unique code used in backend logic (e.g., first_recording, play_100)';
COMMENT ON COLUMN sexy_achievements.category IS 'Achievement category: recordings, playback, social, special';
-- ====================
-- Table: sexy_user_achievements
-- ====================
-- Junction table tracking unlocked achievements per user
CREATE TABLE IF NOT EXISTS sexy_user_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
achievement_id UUID NOT NULL REFERENCES sexy_achievements(id) ON DELETE CASCADE,
progress INTEGER DEFAULT 0, -- Current progress toward unlocking
date_unlocked TIMESTAMP WITH TIME ZONE, -- NULL if not yet unlocked
UNIQUE(user_id, achievement_id)
);
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON sexy_user_achievements(user_id);
CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement ON sexy_user_achievements(achievement_id);
CREATE INDEX IF NOT EXISTS idx_user_achievements_unlocked ON sexy_user_achievements(date_unlocked) WHERE date_unlocked IS NOT NULL;
COMMENT ON TABLE sexy_user_achievements IS 'Tracks which achievements users have unlocked';
COMMENT ON COLUMN sexy_user_achievements.progress IS 'Current progress (e.g., 7/10 recordings created)';
COMMENT ON COLUMN sexy_user_achievements.date_unlocked IS 'NULL if achievement not yet unlocked';
-- ====================
-- Table: sexy_user_stats
-- ====================
-- Cached aggregate statistics for efficient leaderboard queries
CREATE TABLE IF NOT EXISTS sexy_user_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
total_raw_points INTEGER DEFAULT 0, -- Sum of all points (no decay)
total_weighted_points NUMERIC(10,2) DEFAULT 0, -- Time-weighted score for rankings
recordings_count INTEGER DEFAULT 0, -- Number of published recordings
playbacks_count INTEGER DEFAULT 0, -- Number of recordings played
comments_count INTEGER DEFAULT 0, -- Number of comments on recordings
achievements_count INTEGER DEFAULT 0, -- Number of unlocked achievements
last_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW() -- Cache timestamp
);
CREATE INDEX IF NOT EXISTS idx_user_stats_weighted ON sexy_user_stats(total_weighted_points DESC);
CREATE INDEX IF NOT EXISTS idx_user_stats_user ON sexy_user_stats(user_id);
COMMENT ON TABLE sexy_user_stats IS 'Cached user statistics for fast leaderboard queries';
COMMENT ON COLUMN sexy_user_stats.total_raw_points IS 'Sum of all points without time decay';
COMMENT ON COLUMN sexy_user_stats.total_weighted_points IS 'Time-weighted score using exponential decay (λ=0.005)';
COMMENT ON COLUMN sexy_user_stats.last_updated IS 'Timestamp for cache invalidation';
-- ====================
-- Insert Initial Achievements
-- ====================
-- 🎬 Recordings (Creation)
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
('first_recording', 'First Recording', 'Create your first recording', '🎬', 'recordings', 1, 50, 1),
('recording_10', 'Recording Enthusiast', 'Create 10 recordings', '📹', 'recordings', 10, 100, 2),
('recording_50', 'Prolific Creator', 'Create 50 recordings', '🎥', 'recordings', 50, 500, 3),
('recording_100', 'Recording Master', 'Create 100 recordings', '🏆', 'recordings', 100, 1000, 4),
('featured_recording', 'Featured Creator', 'Get a recording featured', '', 'recordings', 1, 200, 5)
ON CONFLICT (code) DO NOTHING;
-- ▶️ Playback (Consumption)
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
('first_play', 'First Play', 'Play your first recording', '▶️', 'playback', 1, 25, 10),
('play_100', 'Active Player', 'Play 100 recordings', '🎮', 'playback', 100, 250, 11),
('play_500', 'Playback Enthusiast', 'Play 500 recordings', '🔥', 'playback', 500, 1000, 12),
('completionist_10', 'Completionist', 'Complete 10 recordings to 90%+', '', 'playback', 10, 100, 13),
('completionist_100', 'Super Completionist', 'Complete 100 recordings', '💯', 'playback', 100, 500, 14)
ON CONFLICT (code) DO NOTHING;
-- 💬 Social (Community)
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
('first_comment', 'First Comment', 'Leave your first comment', '💬', 'social', 1, 25, 20),
('comment_50', 'Conversationalist', 'Leave 50 comments', '💭', 'social', 50, 200, 21),
('comment_250', 'Community Voice', 'Leave 250 comments', '📣', 'social', 250, 750, 22)
ON CONFLICT (code) DO NOTHING;
-- ⭐ Special (Milestones)
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
('early_adopter', 'Early Adopter', 'Join in the first month', '🚀', 'special', 1, 500, 30),
('one_year', 'One Year Anniversary', 'Be a member for 1 year', '🎂', 'special', 1, 1000, 31),
('balanced_creator', 'Balanced Creator', '50 recordings + 100 plays', '⚖️', 'special', 1, 500, 32),
('top_10_rank', 'Top 10 Leaderboard', 'Reach top 10 on leaderboard', '🏅', 'special', 1, 2000, 33)
ON CONFLICT (code) DO NOTHING;
-- ====================
-- Verification Queries
-- ====================
-- Count tables created
SELECT
'sexy_recording_plays' as table_name,
COUNT(*) as row_count
FROM sexy_recording_plays
UNION ALL
SELECT 'sexy_user_points', COUNT(*) FROM sexy_user_points
UNION ALL
SELECT 'sexy_achievements', COUNT(*) FROM sexy_achievements
UNION ALL
SELECT 'sexy_user_achievements', COUNT(*) FROM sexy_user_achievements
UNION ALL
SELECT 'sexy_user_stats', COUNT(*) FROM sexy_user_stats;
-- Show created achievements
SELECT
category,
COUNT(*) as achievement_count
FROM sexy_achievements
GROUP BY category
ORDER BY category;

View File

@@ -7,13 +7,16 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"build:bundle": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/bundle build", "build:bundle": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/bundle build",
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build", "build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
"dev:data": "cd ../compose/data && docker compose up -d", "dev": "pnpm build:bundle && docker compose up -d && pnpm --filter @sexy.pivoine.art/frontend dev",
"dev:directus": "cd ../compose/sexy && docker compose --env-file=.env.local up -d directus", "schema:export": "docker compose exec directus node /directus/cli.js schema snapshot --yes /tmp/snapshot.yml && docker compose cp directus:/tmp/snapshot.yml ./directus.yml && docker compose exec db pg_dump -U sexy --schema-only sexy > schema.sql",
"dev": "pnpm dev:data && pnpm dev:directus && pnpm --filter @sexy.pivoine.art/frontend dev" "schema:import": "docker compose exec -T postgres psql -U sexy sexy < schema.sql && docker compose cp ./directus.yml directus:/tmp/snapshot.yml && docker compose exec directus node /directus/cli.js schema apply --yes /tmp/snapshot.yml"
}, },
"keywords": [], "keywords": [],
"author": "", "author": {
"license": "ISC", "name": "Valknar",
"email": "valknar@pivoine.art"
},
"license": "MIT",
"packageManager": "pnpm@10.19.0", "packageManager": "pnpm@10.19.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

View File

@@ -1,4 +0,0 @@
PUBLIC_API_URL=https://sexy.pivoine.art/api
PUBLIC_URL=https://sexy.pivoine.art
PUBLIC_UMAMI_ID=
PUBLIC_UMAMI_SCRIPT=

2667
schema.sql Normal file

File diff suppressed because it is too large Load Diff