11 Commits
Author SHA1 Message Date
dimitar 7378d37b36 sync working as intended 2026-02-04 00:52:05 +01:00
dimitar 9ca05f5ea1 delete/archive implemented 2026-02-03 23:14:36 +01:00
dimitar 89b8687431 video func implemented 2026-02-03 21:18:25 +01:00
dimitar 3bc534489d img positioning fixed 2026-02-03 20:03:46 +01:00
dimitar 6467e21019 markdown img insert work as intended 2026-02-03 19:53:29 +01:00
dimitar 28609a6492 img shown succesfully 2026-02-03 18:47:42 +01:00
dimitar 7d3bc2a014 setup completed 2026-02-03 17:45:38 +01:00
dimitar acfbc80d1a unpublish fix 2026-02-01 13:57:16 +01:00
dimitar 0c2433cac6 docker implementation 2026-02-01 13:32:37 +01:00
dimitar 7c7bb45446 admin dashboard 2026-01-29 11:27:45 +01:00
dimitar 2ff76ffda5 fixed strapi webhook endpoint 2026-01-29 09:40:23 +01:00
68 changed files with 36349 additions and 176 deletions

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
# Administrator Guide - Placebo.mk
## Overview
Placebo.mk is a Macedonian news site with a sarcastic tone. This guide covers system administration, user management, and technical operations.
## System Architecture
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS
- **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi for content management
- **Database**: SQLite with TypeORM entities
## Environment Setup
### Backend Configuration
```bash
cd backend
cp .env.example .env
# Configure environment variables:
# DATABASE_PATH=./data/app.db
# PORT=3000
# NODE_ENV=development
npm install
npm run start:dev
```
### Frontend Configuration
```bash
cd frontend
cp .env.example .env
# Configure environment variables:
# VITE_API_URL=http://localhost:3000
npm install
npm run dev
```
### CMS (Strapi) Configuration
```bash
cd cms/cms
cp .env.example .env
# Configure Strapi environment variables:
# DATABASE_CLIENT=sqlite
# DATABASE_FILENAME=./data/app.db
# API_TOKEN_SALT=
# ADMIN_JWT_SECRET=
npm run develop
```
## Database Management
### Entities Overview
- **Articles**: News articles with status (draft/published/archived)
- **Authors**: Writer profiles with permissions
- **Categories**: Hierarchical content organization
- **Live Blogs**: Real-time event coverage
- **Live Blog Updates**: Individual updates within live blogs
### Database Operations
```bash
# Run migrations (if implemented)
npm run migration:run
# Check database schema
npm run schema:sync
# Backup database
cp backend/data/app.db backend/data/backup-$(date +%Y%m%d).db
```
## User Management
### Author Management
Authors are stored in the `authors` table with:
- Basic profile (name, bio, avatar)
- Unique slug for URLs
- `isActive` flag for permissions
- Relations to articles and live blogs
### CMS User Roles
1. **Super Admin**: Full system access
2. **Admin**: Content and user management
3. **Editor**: Content creation and editing
4. **Author**: Limited to assigned content
### Creating New Authors
```sql
INSERT INTO authors (id, name, slug, bio, avatar, isActive, createdAt, updatedAt)
VALUES (
uuid(),
'Author Name',
'author-slug',
'Author bio',
'avatar-url.jpg',
true,
datetime('now'),
datetime('now')
);
```
## Content Management
### Article Workflow
1. **Draft**: Initial creation phase
2. **Published**: Publicly visible
3. **Archived**: No longer public but retained
### Live Blog Management
Live blogs support real-time updates:
- **Draft**: Preparation phase
- **Live**: Active event coverage
- **Ended**: Coverage complete
- **Archived**: Historical reference
## API Management
### Available Endpoints
```
GET /api/v1/articles # List articles
GET /api/v1/articles/:id # Get single article
GET /api/v1/articles/slug/:slug # Get article by slug
POST /api/v1/articles # Create article
PUT /api/v1/articles/:id # Update article
DELETE /api/v1/articles/:id # Delete article
GET /api/v1/live-blogs # List live blogs
GET /api/v1/live-blogs/:id # Get live blog
POST /api/v1/live-blogs # Create live blog
PUT /api/v1/live-blogs/:id # Update live blog
GET /api/v1/authors # List authors
GET /api/v1/categories # List categories
```
### API Authentication
Configure JWT tokens for secure API access:
```typescript
// In .env
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=24h
```
## CMS Administration
### Strapi Admin Panel
Access at: `http://localhost:1337/admin`
### Content Types Configuration
Articles support:
- Rich text content
- Multiple media files (images, files, videos, audio)
- Featured images
- Author attribution
### Media Management
- Upload limits configured in Strapi settings
- Image optimization handled automatically
- File organization in `/uploads` directory
## Performance Monitoring
### Backend Monitoring
```bash
# Check application logs
npm run start:prod
# Monitor performance
npm run start:prod -- --inspect
```
### Frontend Performance
```bash
# Build for production
cd frontend
npm run build
# Analyze bundle size
npm run build:analyze
```
## Security Considerations
### Authentication
- Implement proper JWT handling
- Use secure password hashing
- Set appropriate CORS policies
### Data Protection
- Regular database backups
- Environment variable protection
- Input validation and sanitization
### CMS Security
- Regular Strapi updates
- Role-based access control
- Media file scanning
## Backup and Recovery
### Automated Backups
```bash
# Create backup script
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p backups
cp backend/data/app.db backups/app_$DATE.db
tar -czf backups/uploads_$DATE.tar.gz cms/cms/public/uploads/
```
### Recovery Process
1. Stop all services
2. Restore database from backup
3. Restore media files
4. Restart services
5. Verify functionality
## Troubleshooting
### Common Issues
#### Database Connection Errors
```bash
# Check database file exists
ls -la backend/data/app.db
# Verify permissions
chmod 664 backend/data/app.db
```
#### Strapi Admin Access
```bash
# Reset admin password
npm run strapi admin:reset-password
```
#### Frontend Build Issues
```bash
# Clear cache
rm -rf node_modules package-lock.json
npm install
# Check environment variables
cat .env
```
## Maintenance Tasks
### Daily
- Monitor system logs
- Check backup completion
- Review performance metrics
### Weekly
- Update dependencies
- Review content moderation queue
- Security scan
### Monthly
- Database maintenance
- Archive old content
- Performance optimization review
## Scaling Considerations
### Database Scaling
- Consider PostgreSQL for high traffic
- Implement read replicas
- Optimize queries and indexes
### CDN Integration
- Configure CDN for static assets
- Optimize image delivery
- Implement caching strategies
### Monitoring Setup
- Application performance monitoring
- Error tracking and alerting
- User analytics and reporting
+461
View File
@@ -0,0 +1,461 @@
# Placebo.mk Deployment Guide
## Overview
This document outlines the deployment strategy for Placebo.mk - a Macedonian news site with sarcastic tone, built with TanStack (React), NestJS, and Strapi CMS.
## Architecture
- **Frontend**: TanStack (React 19 + Query + Router) + Vite + Tailwind CSS
- **Backend**: NestJS + TypeORM + PostgreSQL (migrated from SQLite)
- **CMS**: Strapi with PostgreSQL
- **Deployment**: Docker + Coolify on VPS
## Deployment Options
### Option 1: Coolify + VPS (Recommended)
**Pros**: Self-hosted, cost-effective, full control, Macedonian audience optimized
**Cons**: Requires server management
### Option 2: Platform-as-a-Service
**Pros**: No server management, automatic scaling
**Cons**: More expensive, less control
## Coolify + VPS Deployment
### Phase 1: Infrastructure Setup
#### 1.1 VPS Requirements
- **Provider**: Hetzner (Germany) or DigitalOcean (Amsterdam)
- **Specs**: 4GB RAM, 2 vCPU, 80GB SSD (€10-15/month)
- **OS**: Ubuntu 22.04 LTS
- **Domain**: placebo.mk
#### 1.2 Install Coolify
```bash
# On VPS
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
Coolify automatically installs:
- Docker and Docker Compose
- Traefik reverse proxy
- Let's Encrypt SSL
- Admin interface
#### 1.3 Domain Configuration
1. Purchase `placebo.mk` domain
2. Configure DNS A records:
- `placebo.mk` → VPS IP
- `www.placebo.mk` → VPS IP
- `api.placebo.mk` → VPS IP
- `cms.placebo.mk` → VPS IP
### Phase 2: Database Migration
#### 2.1 PostgreSQL Setup
Create PostgreSQL database via Coolify one-click apps:
- Database name: `placebomk`
- Username: `placebo`
- Password: Generate secure password
#### 2.2 Migrate from SQLite
Detailed migration plan available in `scripts/migrate-to-postgres.md`
Run migration script:
```bash
# Test migration locally first
docker-compose up -d postgres
./scripts/migrate-data.sh
# Production migration
cd backend
npm run migrate:postgres
```
#### 2.3 Migration Strategy
1. **Development**: Use SQLite for local development
2. **Staging**: PostgreSQL with test data
3. **Production**: PostgreSQL with migrated data
4. **Backup**: Maintain SQLite backups during transition
### Phase 3: Application Deployment
#### 3.1 Connect GitHub Repository
1. Connect your GitHub repo to Coolify
2. Create three applications:
- `placebo-frontend` (from `/frontend`)
- `placebo-backend` (from `/backend`)
- `placebo-cms` (from `/cms/cms`)
#### 3.2 Configure Applications
**Backend Configuration:**
- **Build Command**: `npm run build`
- **Start Command**: `npm run start:prod`
- **Port**: 3000
- **Environment Variables**:
```
NODE_ENV=production
DATABASE_URL=postgresql://placebo:PASSWORD@postgres:5432/placebomk
CORS_ORIGIN=https://placebo.mk
JWT_SECRET=your-secure-jwt-secret
```
**Frontend Configuration:**
- **Build Command**: `npm run build`
- **Output Directory**: `dist`
- **Environment Variables**:
```
VITE_API_URL=https://api.placebo.mk/api/v1
VITE_STRAPI_URL=https://cms.placebo.mk
```
**CMS Configuration:**
- **Build Command**: `npm run build`
- **Start Command**: `npm run start`
- **Port**: 1337
- **Environment Variables**:
```
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=placebo
DATABASE_PASSWORD=PASSWORD
```
#### 3.3 Configure Domains in Coolify
- `placebo.mk` → Frontend application
- `api.placebo.mk` → Backend API
- `cms.placebo.mk` → Strapi CMS admin
### Phase 4: Security Configuration
#### 4.1 Environment Variables
Store sensitive data in Coolify environment variables:
- Database passwords
- JWT secrets
- API keys
- Strapi secrets
#### 4.2 SSL Certificates
Coolify automatically:
- Requests Let's Encrypt certificates
- Configures HTTPS redirect
- Auto-renews certificates
#### 4.3 Security Headers
Configure in Coolify:
- HSTS enabled
- CSP headers
- X-Frame-Options
- X-Content-Type-Options
### Phase 5: Monitoring & Backup
#### 5.1 Monitoring
- **Coolify built-in monitoring**: Resource usage, uptime
- **Application logs**: Access via Coolify UI
- **Health checks**: Configure endpoints
#### 5.2 Backup Strategy
1. **Database backups**: Daily via Coolify to S3-compatible storage
2. **Media files**: Strapi uploads to cloud storage
3. **Configuration**: Export Coolify settings regularly
#### 5.3 Backup Configuration
```yaml
# Coolify backup settings
backup:
schedule: "0 2 * * *" # Daily at 2 AM
retention: 30 # Keep 30 days
destination: s3://backup-bucket/placebo-mk
```
## Docker Setup
### Docker Files Created
```
placeboMk/
├── docker-compose.yml # Production setup with PostgreSQL
├── docker-compose.dev.yml # Development setup with hot reload
├── backend/
│ ├── Dockerfile # Production Dockerfile
│ └── Dockerfile.dev # Development Dockerfile
├── frontend/
│ ├── Dockerfile # Production Dockerfile (Nginx)
│ ├── Dockerfile.dev # Development Dockerfile
│ └── nginx.conf # Nginx configuration
└── cms/cms/
├── Dockerfile # Production Dockerfile
└── Dockerfile.dev # Development Dockerfile
```
### Local Development with Docker
#### Prerequisites
- Docker Desktop
- Docker Compose
- Node.js 20+ (optional, for local development without Docker)
#### Quick Start - Development Mode
```bash
# Clone repository
git clone https://github.com/your-org/placeboMk.git
cd placeboMk
# Start development environment with hot reload
docker-compose -f docker-compose.dev.yml up -d
# Access applications:
# Frontend: http://localhost:5173
# Backend API: http://localhost:3000
# Strapi CMS: http://localhost:1337/admin
```
#### Quick Start - Production Mode (Local Testing)
```bash
# Start production environment
docker-compose up -d
# Access applications:
# Frontend: http://localhost:3001
# Backend API: http://localhost:3000
# Strapi CMS: http://localhost:1337
# PostgreSQL: localhost:5432
```
#### Test Docker Setup
```bash
# Run comprehensive test
./scripts/test-docker.sh
# Or manually test
docker-compose build
docker-compose up -d
docker-compose ps
```
### Development Commands
```bash
# Development environment
docker-compose -f docker-compose.dev.yml up -d # Start dev
docker-compose -f docker-compose.dev.yml down # Stop dev
docker-compose -f docker-compose.dev.yml logs -f # View dev logs
# Production environment (local testing)
docker-compose up -d # Start prod
docker-compose down # Stop prod
docker-compose logs -f # View prod logs
docker-compose build # Rebuild images
# Database operations
docker-compose exec postgres psql -U placebo_user -d placebo_db # PostgreSQL shell
docker-compose exec backend npm run migration:run # Run migrations
# Service management
docker-compose restart backend # Restart backend
docker-compose restart frontend # Restart frontend
docker-compose restart cms # Restart CMS
```
## Production Checklist
### Pre-Deployment
- [ ] Domain DNS propagated (48 hours)
- [ ] SSL certificates issued
- [ ] Database migrated and tested
- [ ] Environment variables configured
- [ ] Backup system tested
- [ ] Monitoring configured
### Deployment Day
1. **Morning**: Final testing of staging environment
2. **Afternoon**: Deploy to production (low traffic time)
3. **Evening**: Monitor performance and fix issues
### Post-Deployment
- [ ] Verify all services are running
- [ ] Test critical user flows
- [ ] Check SSL certificates
- [ ] Verify backups are working
- [ ] Monitor error rates
## Troubleshooting
### Common Issues
#### 1. Database Connection Failed
```bash
# Check PostgreSQL logs
docker-compose logs postgres
# Test connection
docker-compose exec postgres psql -U placebo -d placebomk
```
#### 2. Build Failures
- Check Node.js version compatibility
- Verify package.json dependencies
- Check build logs in Coolify
#### 3. SSL Certificate Issues
- Verify DNS records
- Check Traefik logs: `docker-compose logs traefik`
- Manually renew: `docker-compose exec traefik traefik cert`
#### 4. CORS Errors
- Verify CORS_ORIGIN environment variable
- Check backend CORS configuration
- Test API calls from frontend
### Debug Commands
```bash
# Check container status
docker-compose ps
# View application logs
docker-compose logs --tail=100 backend
# Shell into container
docker-compose exec backend sh
# Check network connectivity
docker-compose exec backend curl http://postgres:5432
```
## Performance Optimization
### Frontend
- Enable Vite build optimization
- Configure CDN for static assets
- Implement code splitting
- Optimize images
### Backend
- Implement Redis caching
- Database query optimization
- Enable compression
- Configure connection pooling
### Database
- Add appropriate indexes
- Regular vacuum and analyze
- Connection pool tuning
- Read replicas for high traffic
## Scaling Strategy
### Vertical Scaling (First Step)
- Upgrade VPS: 8GB RAM, 4 vCPU
- Increase PostgreSQL memory
- Add Redis cache
### Horizontal Scaling
- Add application replicas
- Load balancing with Traefik
- Database read replicas
- CDN for static assets
### Cost Optimization
- Right-size VPS resources
- Use object storage for media
- Implement caching to reduce database load
- Monitor and optimize queries
## Maintenance Schedule
### Daily
- Check application logs
- Verify backup completion
- Monitor resource usage
- Review error rates
### Weekly
- Update dependencies (security patches)
- Review access logs
- Test restore from backup
- Clean up old logs
### Monthly
- Security audit
- Performance review
- Cost optimization review
- Update deployment documentation
## Emergency Procedures
### Database Corruption
1. Stop affected services
2. Restore from latest backup
3. Verify data integrity
4. Restart services
### Application Crash
1. Check logs for root cause
2. Rollback to previous version
3. Fix issue in development
4. Deploy fix
### DDoS Attack
1. Enable rate limiting
2. Block malicious IPs
3. Scale up resources temporarily
4. Contact hosting provider
## Support Contacts
### Technical Support
- **Coolify Documentation**: https://coolify.io/docs
- **Docker Support**: https://docs.docker.com
- **PostgreSQL Docs**: https://www.postgresql.org/docs
### Macedonian Hosting Providers
- **Hetzner**: German-based, good for Macedonian audience
- **DigitalOcean**: Amsterdam datacenter
- **Local Providers**: Check for Macedonian hosting companies
## Cost Estimates
| Resource | Monthly Cost | Annual Cost |
|----------|-------------|-------------|
| VPS (4GB/2CPU) | €12 | €144 |
| Domain (placebo.mk) | €15/year | €15 |
| Backup Storage (100GB) | €5 | €60 |
| **Total** | **€17/month** | **€219/year** |
## Success Metrics
### Technical Metrics
- Uptime: >99.9%
- Page load time: <2 seconds
- API response time: <200ms
- Error rate: <0.1%
### Business Metrics
- Monthly visitors
- Article publication rate
- User engagement
- Revenue (if applicable)
## Next Steps
1. **Set up development environment** with Docker
2. **Test migration** from SQLite to PostgreSQL
3. **Deploy to staging** environment
4. **Perform load testing**
5. **Go live** with production deployment
## Changelog
### v1.0.0 - Initial Deployment
- Dockerized application stack
- PostgreSQL migration
- Coolify deployment configuration
- Basic monitoring and backup
### Future Improvements
- Redis caching implementation
- CDN integration
- Advanced monitoring (Prometheus/Grafana)
- Automated testing pipeline
- Macedonian language optimization
+441
View File
@@ -0,0 +1,441 @@
# Placebo.mk Docker Setup
## Overview
Complete Docker configuration for Placebo.mk - a Macedonian news site with sarcastic tone. Includes both development and production setups.
## Architecture
- **Frontend**: TanStack (React 19) + Vite + Tailwind CSS
- **Backend**: NestJS + TypeORM
- **CMS**: Strapi
- **Database**: PostgreSQL (production), SQLite (development)
- **Reverse Proxy**: Nginx (production)
## Quick Start
### Development Environment (Hot Reload)
```bash
# Start development environment
docker-compose -f docker-compose.dev.yml up -d
# Services available at:
# Frontend: http://localhost:5173
# Backend API: http://localhost:3000
# CMS Admin: http://localhost:1337/admin
# PostgreSQL: localhost:5432
```
### Production Environment (Local Testing)
```bash
# Start production environment
docker-compose up -d
# Services available at:
# Frontend: http://localhost:3001
# Backend API: http://localhost:3000
# CMS: http://localhost:1337
```
### Test Everything
```bash
# Run comprehensive test
chmod +x scripts/test-docker.sh
./scripts/test-docker.sh
```
## Docker Files Structure
### Production Dockerfiles
- `backend/Dockerfile` - NestJS API with Node.js 20 Alpine
- `frontend/Dockerfile` - React app built with Vite, served by Nginx
- `cms/cms/Dockerfile` - Strapi CMS with PostgreSQL support
- `frontend/nginx.conf` - Nginx configuration for frontend
### Development Dockerfiles
- `backend/Dockerfile.dev` - Development with hot reload
- `frontend/Dockerfile.dev` - Development with Vite dev server
- `cms/cms/Dockerfile.dev` - Strapi development mode
### Docker Compose Files
- `docker-compose.yml` - Production setup with all services
- `docker-compose.dev.yml` - Development setup with volume mounts
## Services
### 1. PostgreSQL Database
- **Image**: `postgres:16-alpine`
- **Port**: 5432
- **Database**: `placebo_db`
- **User**: `placebo_user`
- **Password**: `placebo_password`
- **Volume**: `postgres_data` (persistent storage)
### 2. Backend API (NestJS)
- **Production Port**: 3000
- **Development Port**: 3000 (hot reload)
- **Health Check**: `GET /health`
- **Environment**: See `backend/.env.example`
### 3. Frontend (TanStack React)
- **Production Port**: 3001 (via Nginx)
- **Development Port**: 5173 (Vite dev server)
- **Build Tool**: Vite
- **Environment**: See `frontend/.env.example`
### 4. CMS (Strapi)
- **Port**: 1337
- **Admin**: `/admin`
- **Health Check**: `GET /_health`
- **Environment**: See `cms/cms/.env.example`
### 5. Nginx (Production Only)
- **Port**: 80
- **Configuration**: `frontend/nginx.conf`
- **Features**: SSL, compression, security headers, React Router support
## Environment Variables
### Backend (.env)
```bash
NODE_ENV=production
DATABASE_TYPE=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
DATABASE_NAME=placebo_db
JWT_SECRET=your-jwt-secret-key-change-in-production
CORS_ORIGIN=http://localhost:5173,http://localhost:3001
```
### Frontend (.env)
```bash
NODE_ENV=production
VITE_API_URL=http://localhost:3000
VITE_CMS_URL=http://localhost:1337
```
### CMS (.env)
```bash
NODE_ENV=production
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=placebo_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
JWT_SECRET=your-jwt-secret-key-change-in-production
```
## Common Commands
### Development
```bash
# Start development environment
docker-compose -f docker-compose.dev.yml up -d
# View logs
docker-compose -f docker-compose.dev.yml logs -f
# Stop development
docker-compose -f docker-compose.dev.yml down
# Rebuild development images
docker-compose -f docker-compose.dev.yml build
```
### Production (Local Testing)
```bash
# Start production environment
docker-compose up -d
# View logs
docker-compose logs -f
# Stop production
docker-compose down
# Rebuild production images
docker-compose build
```
### Database Operations
```bash
# Access PostgreSQL shell
docker-compose exec postgres psql -U placebo_user -d placebo_db
# Backup database
docker-compose exec postgres pg_dump -U placebo_user placebo_db > backup.sql
# Restore database
docker-compose exec -T postgres psql -U placebo_user placebo_db < backup.sql
```
### Service Management
```bash
# Restart specific service
docker-compose restart backend
docker-compose restart frontend
docker-compose restart cms
# View service status
docker-compose ps
# View resource usage
docker-compose stats
```
## Health Checks
All services include health checks:
### Backend
```bash
curl http://localhost:3000/health
```
### CMS
```bash
curl http://localhost:1337/_health
```
### Frontend
```bash
curl http://localhost:3001
```
### PostgreSQL
```bash
docker-compose exec postgres pg_isready -U placebo_user -d placebo_db
```
## Database Migration
### SQLite to PostgreSQL Migration
Detailed migration plan in `scripts/migrate-to-postgres.md`
```bash
# Export SQLite data
sqlite3 backend/data.db .dump > backend-data.sql
# Transform for PostgreSQL
python scripts/transform-sqlite-to-postgres.py backend-data.sql > backend-postgres.sql
# Import to PostgreSQL
docker-compose exec -T postgres psql -U placebo_user placebo_db < backend-postgres.sql
```
## Troubleshooting
### Common Issues
#### 1. Port Conflicts
```bash
# Check what's using port 3000
sudo lsof -i :3000
# Or use different ports in docker-compose.yml
```
#### 2. Docker Build Failures
```bash
# Clear Docker cache
docker system prune -a
# Rebuild with no cache
docker-compose build --no-cache
```
#### 3. Database Connection Issues
```bash
# Check PostgreSQL logs
docker-compose logs postgres
# Test connection from backend
docker-compose exec backend node -e "console.log('Testing DB connection...')"
```
#### 4. Permission Issues
```bash
# Fix volume permissions
sudo chown -R $USER:$USER .
# Rebuild containers
docker-compose down && docker-compose up -d
```
### Debug Commands
```bash
# Shell into container
docker-compose exec backend sh
docker-compose exec frontend sh
docker-compose exec cms sh
# View container details
docker-compose exec backend env
docker-compose exec frontend env
# Check network connectivity
docker-compose exec backend curl http://postgres:5432
docker-compose exec frontend curl http://backend:3000/health
```
## Deployment to Coolify
### 1. Prepare for Production
```bash
# Update environment variables for production
cp backend/.env.example backend/.env.production
cp frontend/.env.example frontend/.env.production
cp cms/cms/.env.example cms/cms/.env.production
# Build production images
docker-compose build
# Test locally
docker-compose up -d
./scripts/test-docker.sh
```
### 2. Coolify Configuration
1. Connect GitHub repository to Coolify
2. Create three applications:
- Frontend (from `/frontend`)
- Backend (from `/backend`)
- CMS (from `/cms/cms`)
3. Configure environment variables
4. Set up PostgreSQL database
5. Configure domains and SSL
### 3. Database Migration
1. Export production SQLite data
2. Transform for PostgreSQL
3. Import to Coolify PostgreSQL
4. Verify data integrity
## Performance Optimization
### Frontend
- Nginx gzip compression enabled
- Static asset caching (1 year)
- Security headers configured
- React Router support
### Backend
- Health checks every 30 seconds
- Connection pooling with PostgreSQL
- CORS configured for frontend domains
### Database
- PostgreSQL connection pooling
- Regular backups via volumes
- Health checks with `pg_isready`
## Security
### Implemented Security Features
1. **Non-root users**: All containers run as non-root
2. **Health checks**: Automatic service monitoring
3. **Environment variables**: Secrets stored in .env files
4. **Network isolation**: Services on internal network
5. **Security headers**: X-Frame-Options, CSP, etc.
6. **SSL ready**: Nginx configured for HTTPS
### Security Best Practices
1. Never commit `.env` files to Git
2. Use strong passwords for PostgreSQL
3. Regular security updates
4. Monitor Docker logs
5. Backup database regularly
## Monitoring
### Built-in Monitoring
```bash
# View container logs
docker-compose logs -f
# Check container status
docker-compose ps
# Monitor resource usage
docker-compose stats
# View health check status
docker inspect --format='{{.State.Health.Status}}' placebo-backend
```
### Custom Monitoring
Add to `docker-compose.yml`:
```yaml
monitoring:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
```
## Backup and Recovery
### Database Backup
```bash
# Daily backup script
docker-compose exec postgres pg_dump -U placebo_user placebo_db > backup-$(date +%Y%m%d).sql
# Compress backup
gzip backup-$(date +%Y%m%d).sql
```
### Volume Backup
```bash
# Backup PostgreSQL volume
docker run --rm -v postgres_data:/source -v $(pwd)/backups:/backup alpine tar czf /backup/postgres-$(date +%Y%m%d).tar.gz -C /source .
```
### Recovery
```bash
# Restore database
docker-compose exec -T postgres psql -U placebo_user placebo_db < backup.sql
# Restore volume
docker run --rm -v postgres_data:/target -v $(pwd)/backups:/backup alpine tar xzf /backup/postgres-backup.tar.gz -C /target
```
## Contributing
### Adding New Services
1. Create Dockerfile in service directory
2. Add service to `docker-compose.yml`
3. Configure environment variables
4. Add health check
5. Test locally
6. Update documentation
### Updating Dependencies
```bash
# Rebuild with updated dependencies
docker-compose build --no-cache
# Update package.json in mounted volumes
docker-compose exec backend npm update
docker-compose exec frontend npm update
docker-compose exec cms npm update
```
## Support
### Documentation
- `DEPLOYMENT.md` - Complete deployment guide
- `scripts/migrate-to-postgres.md` - Database migration plan
- `AGENTS.md` - Development guidelines
### Troubleshooting Resources
- Docker Documentation: https://docs.docker.com
- PostgreSQL Documentation: https://www.postgresql.org/docs
- Strapi Documentation: https://docs.strapi.io
- Coolify Documentation: https://coolify.io/docs
### Macedonian Resources
- Local hosting providers
- Macedonian domain registration
- GDPR compliance for Macedonian users
+314
View File
@@ -0,0 +1,314 @@
# Publisher & Content Creator Guide - Placebo.mk
## Overview
Placebo.mk is a Macedonian news site with a sarcastic tone. This guide covers content creation, publishing workflows, and best practices for writers and editors.
## Getting Started
### Accessing the CMS
1. Navigate to: `http://your-domain.com/admin`
2. Log in with your credentials
3. Select "Content Manager" from the sidebar
### Your Dashboard
- **Content Manager**: Create and edit articles
- **Media Library**: Upload and manage images/files
- **Profile**: Update your author information
## Content Creation Workflow
### 1. Planning Your Article
Before writing:
- Choose a compelling headline
- Research your topic thoroughly
- Gather supporting images/media
- Plan your article structure
### 2. Creating a New Article
#### Basic Article Setup
1. Click **+ Create new entry** in Content Manager
2. Select **Article** content type
3. Fill in required fields:
- **Title**: Catchy, SEO-friendly headline
- **Author**: Your name (auto-populated)
- **Content**: Main article body
#### Article Fields Explained
- **Title**: Maximum 100 characters, include keywords
- **Content**: Rich text editor with formatting options
- **Media**: Upload images, videos, documents
- **Featured Image**: Main article thumbnail
- **Status**: Draft → Published → Archived
### 3. Writing Best Practices
#### Headline Guidelines
- Use active voice and strong verbs
- Include location if relevant (e.g., "Скопје:")
- Keep under 100 characters
- Ask questions or make bold statements
#### Content Structure
```
1. Lead Paragraph (2-3 sentences)
- Who, what, where, when, why
- Most important information first
2. Body Paragraphs (3-5 paragraphs)
- Supporting details and quotes
- Background information
- Analysis and context
3. Conclusion (1-2 paragraphs)
- Summary or call to action
- Future implications
```
#### Writing Style
- **Tone**: Sarcastic but informative
- **Language**: Macedonian with local context
- **Length**: 300-800 words for news, 800-1500 for features
- **Voice**: Conversational, engaging, slightly cynical
### 4. Media Integration
#### Adding Images
1. Click **Media** field in article editor
2. Click **Add new** or select from library
3. Drag and drop or browse files
4. Add alt text for accessibility
5. Choose image size and alignment
#### Image Guidelines
- **Featured Image**: 1200x630px minimum
- **Inline Images**: 800px width maximum
- **File Size**: Under 2MB per image
- **Formats**: JPG, PNG, WebP
#### Video and Audio
- Upload MP4 videos (under 100MB)
- Embed YouTube/Vimeo links
- Add MP3 audio files for interviews
## Publishing Workflow
### Article Status Management
#### Draft Status
- Initial creation phase
- Auto-saves every 30 seconds
- Only visible to you and editors
#### Review Process
1. Submit for review when ready
2. Editor reviews content and style
3. Request changes or approve
4. Editor may edit directly
#### Published Status
- Live on the website
- Publicly accessible
- Appears in category listings
- Indexed by search engines
#### Archived Status
- No longer public
- Retained for reference
- Can be republished if needed
### Publishing Best Practices
#### Before Publishing
- [ ] Proofread for spelling/grammar
- [ ] Check all facts and sources
- [ ] Test all links
- [ ] Optimize images for web
- [ ] Add relevant tags
- [ ] Set appropriate category
#### Publishing Schedule
- **Breaking News**: Publish immediately
- **Features**: Schedule for peak traffic (10:00-14:00)
- **Analysis**: Publish by 17:00 for evening readers
## Live Blogging
### When to Use Live Blogs
- Ongoing events (protests, sports, conferences)
- Developing news stories
- Election coverage
- Emergency situations
### Creating a Live Blog
1. Navigate to **Live Blogs** in Content Manager
2. Click **+ Create new entry**
3. Fill in basic information:
- **Title**: Event name + "LIVE"
- **Description**: Brief event summary
- **Status**: Set to "Live" when ready
### Live Blog Updates
Each update should include:
- **Timestamp**: Auto-generated
- **Content**: New information (1-3 sentences)
- **Author**: Your attribution
- **Media**: Relevant photos/videos
#### Update Guidelines
- Keep updates concise and factual
- Post major developments immediately
- Use quotes when available
- Add context for new followers
- Pin important updates
## Content Categories
### Available Categories
- **Политика**: Government, elections, policy
- **Економија**: Business, markets, finance
- **Друштво**: Social issues, culture, lifestyle
- **Спорт**: Sports news and events
- **Технологија**: Tech news and innovations
- **Свет**: International news
- **Култура**: Arts, entertainment, events
### Category Selection
- Choose primary category carefully
- Use tags for additional context
- Consider audience interests
- Follow editorial calendar
## SEO and Discoverability
### Title Optimization
- Include primary keywords
- Use location when relevant
- Ask questions or make statements
- Avoid clickbait (maintain credibility)
### Content SEO
- Naturally include keywords
- Use header tags (H2, H3)
- Add internal links to related articles
- Include external sources when relevant
### Meta Information
- **Excerpt**: 150-character summary
- **Featured Image**: Optimized for social sharing
- **Tags**: 3-5 relevant keywords
## Author Profile Management
### Updating Your Profile
1. Click **Profile** in sidebar
2. Update information:
- **Display Name**: Your byline name
- **Bio**: 100-word professional summary
- **Avatar**: Professional headshot
- **Social Links**: Twitter, LinkedIn, etc.
### Author Best Practices
- Maintain consistent voice across articles
- Build expertise in specific topics
- Engage with reader comments
- Share published articles on social media
## Content Moderation
### Comment Policy
- Comments are enabled on published articles
- Inappropriate content is filtered automatically
- Editors can approve/reject comments manually
### Handling Corrections
1. Mark errors immediately
2. Correct factual mistakes
3. Add correction note at top
4. Notify editor of significant changes
## Analytics and Performance
### Article Metrics
- **Views**: Total page reads
- **Engagement**: Time on page, scroll depth
- **Shares**: Social media interactions
- **Comments**: Reader engagement
### Improving Performance
- Write compelling headlines
- Use relevant, high-quality images
- Publish at optimal times
- Share on social media
- Engage with reader comments
## Tools and Shortcuts
### Editor Shortcuts
- **Ctrl+B**: Bold text
- **Ctrl+I**: Italic text
- **Ctrl+K**: Insert link
- **Ctrl+Z**: Undo
- **Ctrl+Y**: Redo
### Media Management
- Drag and drop uploads
- Bulk image optimization
- Automatic alt text suggestions
- Image cropping and resizing
## Troubleshooting
### Common Issues
#### Editor Not Saving
- Check internet connection
- Refresh page and try again
- Contact administrator if persistent
#### Image Upload Errors
- Verify file size under 2MB
- Check file format (JPG, PNG, WebP)
- Clear browser cache and retry
#### Publishing Problems
- Ensure all required fields completed
- Check article status permissions
- Contact editor for approval
## Best Practices Summary
### Do's
- Write factually accurate content
- Use engaging, sarcastic tone appropriately
- Include relevant, high-quality media
- Proofread thoroughly before publishing
- Engage with reader feedback
- Follow ethical journalism standards
### Don'ts
- Publish unverified information
- Use excessive clickbait
- Ignore reader comments
- Publish without editor review
- Violate copyright laws
- Share confidential information
## Support and Resources
### Getting Help
- **Editor**: Primary contact for content questions
- **Administrator**: Technical support and system issues
- **Style Guide**: Detailed writing and formatting guidelines
### Training Resources
- CMS video tutorials
- Writing style workshops
- SEO best practices guide
- Social media sharing strategies
### Community
- Regular writer meetings
- Content planning sessions
- Feedback and improvement discussions
- Collaboration opportunities
+188
View File
@@ -0,0 +1,188 @@
# Placebo.mk - Development Setup
This project supports two development modes:
1. **Docker Mode** (Recommended): All services run in Docker containers
2. **Local Mode**: Services run locally with npm, PostgreSQL in Docker
## Quick Start
### Option 1: Docker Mode (Recommended)
```bash
# Start all services in Docker
npm run docker:up
# Or use the alias
npm run dev:docker
```
Services will be available at:
- Frontend: http://localhost:5173
- Backend API: http://localhost:3000
- CMS (Strapi): http://localhost:1337/admin
- PostgreSQL: localhost:5432
### Option 2: Local Mode
```bash
# Start PostgreSQL in Docker
docker-compose -f docker-compose.dev.yml up postgres
# In another terminal, start backend and frontend locally
npm run dev:local
```
## Environment Configuration
### Backend Configuration
The backend uses `.env` files for configuration:
- `.env` - Default Docker configuration (committed)
- `.env.local` - Local development configuration (not committed)
- `.env.docker` - Docker configuration template
- `.env.example` - Example configuration
**Switching between modes:**
```bash
# Switch to local mode
cd backend && npm run dev:local
# Switch back to Docker mode
cd backend && npm run dev:reset-env
```
### Frontend Configuration
The frontend also uses `.env` files:
- `.env` - Default Docker configuration (committed)
- `.env.local` - Local development configuration (not committed)
- `.env.docker` - Docker configuration template
**API URL differences:**
- Docker: `VITE_API_URL=http://backend:3000/api/v1`
- Local: `VITE_API_URL=http://localhost:3000/api/v1`
## Development Scripts
### Root Level Scripts
```bash
# Docker commands
npm run docker:up # Start all services
npm run docker:down # Stop all services
npm run docker:build # Rebuild containers
npm run docker:logs # View logs
npm run docker:ps # Check container status
# Development modes
npm run dev:docker # Docker mode (alias for docker:up)
npm run dev:local # Local mode
# Utility
npm run reset:env # Reset .env files to Docker defaults
npm run lint # Run linting on all services
npm run type-check # Run TypeScript checks
```
### Backend Scripts (in backend/ directory)
```bash
npm run dev:docker # Start in Docker mode (uses .env)
npm run dev:local # Start in local mode (uses .env.local)
npm run start:dev # Original NestJS dev command
npm run dev:reset-env # Reset .env to Docker defaults
```
### Frontend Scripts (in frontend/ directory)
```bash
npm run dev:docker # Start in Docker mode
npm run dev:local # Start in local mode
npm run dev # Original Vite dev command
npm run dev:reset-env # Reset .env to Docker defaults
```
## Database Management
### PostgreSQL in Docker
The PostgreSQL container is configured with:
- Host: `localhost:5432` (from host) or `postgres:5432` (from Docker)
- Database: `placebo_backend_db` (backend), `placebo_cms_db` (CMS)
- User: `placebo_user`
- Password: `placebo_password`
### Connecting to Database
```bash
# From host machine
psql -h localhost -p 5432 -U placebo_user -d placebo_backend_db
# From within Docker container
psql -h postgres -p 5432 -U placebo_user -d placebo_backend_db
```
## Project Structure
```
placeboMk/
├── backend/ # NestJS backend API
│ ├── .env # Docker configuration (default)
│ ├── .env.local # Local development configuration
│ └── src/
├── frontend/ # React/Vite frontend
│ ├── .env # Docker configuration (default)
│ ├── .env.local # Local development configuration
│ └── src/
├── cms/cms/ # Strapi CMS
├── docker-compose.yml # Production configuration
├── docker-compose.dev.yml # Development configuration
└── package.json # Root scripts
```
## Troubleshooting
### Common Issues
1. **Port conflicts**
- Check if ports 3000, 5173, 1337, 5432 are free
- Use `lsof -i :3000` to check what's using a port
2. **Database connection errors**
- Ensure PostgreSQL container is running: `docker ps`
- Check logs: `docker logs placebo-postgres-dev`
- Verify credentials in `.env` files
3. **Environment configuration**
- Run `npm run reset:env` to reset to Docker defaults
- Ensure `.env.local` exists for local development
4. **Docker container issues**
- Rebuild containers: `npm run docker:build`
- Clear Docker cache: `docker system prune -f`
### Reset Everything
```bash
# Stop and remove containers
npm run docker:down
docker system prune -f
# Reset environment files
npm run reset:env
# Start fresh
npm run docker:up
```
## Production Deployment
For production, use:
```bash
docker-compose -f docker-compose.yml up -d
```
Production configuration:
- Uses separate `docker-compose.yml`
- Includes Nginx reverse proxy
- No hot reload, optimized builds
- Health checks and proper logging
## Notes
- **Docker-first approach**: `.env` files are configured for Docker by default
- **Local development**: Uses `.env.local` files when running services locally
- **PostgreSQL**: Always runs in Docker for consistency
- **Secrets**: Never commit actual secrets to repository
+31 -4
View File
@@ -1,7 +1,34 @@
PORT=3000
DATABASE_PATH=./database.sqlite
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# Environment Configuration Template
# Copy to .env and adjust values for your setup
# ===== DOCKER MODE (services in containers) =====
# Use these values when running in Docker Compose
# DATABASE_HOST=postgres
# STRAPI_URL=http://cms:1337
# ===== LOCAL MODE (backend locally, DB in Docker) =====
# Use these values when running backend locally with npm run dev
DATABASE_HOST=localhost
STRAPI_URL=http://localhost:1337
# ===== COMMON CONFIGURATION =====
NODE_ENV=development
PORT=3000
# Database Configuration
DATABASE_PORT=5432
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
DATABASE_NAME=placebo_backend_db
DATABASE_SYNCHRONIZE=true
DATABASE_LOGGING=true
# JWT Configuration
JWT_SECRET=dev-jwt-secret-change-in-production
JWT_EXPIRATION=3600
# CORS Configuration
CORS_ORIGIN=http://localhost:5173
# Strapi CMS Configuration
STRAPI_API_TOKEN=your-strapi-api-token-here
+49
View File
@@ -0,0 +1,49 @@
# Backend Dockerfile for Placebo.mk NestJS API
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY package-lock.json* ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy built application from builder stage
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration
COPY --chown=nodejs:nodejs .env.example .env
# Switch to non-root user
USER nodejs
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) throw new Error()})"
# Expose port
EXPOSE 3000
# Start application
CMD ["node", "dist/main.js"]
+28
View File
@@ -0,0 +1,28 @@
# Backend Development Dockerfile for Placebo.mk NestJS API
FROM node:20-alpine
WORKDIR /app
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
# Clear npm cache and install dependencies
RUN npm cache clean --force && \
npm install
# Copy source code
COPY . .
# Fix permissions - use node user that exists in base image
RUN chown -R node:node /app
# Switch to non-root user that exists in base image
USER node
# Expose port
EXPOSE 3000
# Start development server
CMD ["npm", "run", "start:dev"]
Binary file not shown.
Binary file not shown.
+139
View File
@@ -18,6 +18,7 @@
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
@@ -9135,6 +9136,96 @@
"node": ">=8"
}
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9253,6 +9344,45 @@
"node": ">= 0.4"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -10081,6 +10211,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+5 -1
View File
@@ -19,7 +19,10 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
@@ -31,6 +34,7 @@
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
+6 -2
View File
@@ -20,8 +20,12 @@ import {
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DATABASE_PATH ?? './database.sqlite',
type: 'postgres',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
+6 -1
View File
@@ -15,6 +15,11 @@ async function bootstrap() {
});
app.setGlobalPrefix('api/v1');
await app.listen(process.env.PORT ?? 3000);
const port = process.env.PORT ?? 3000;
const host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host);
console.log(`Application is running on: http://${host}:${port}`);
}
void bootstrap();
@@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
@@ -15,6 +16,7 @@ import {
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
import { ArticleStatus } from './entities';
@Controller('articles')
export class ArticlesController {
@@ -54,4 +56,14 @@ export class ArticlesController {
remove(@Param('id') id: string) {
return this.articlesService.remove(id);
}
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.articlesService.archive(id);
}
@Patch(':id/publish')
publish(@Param('id') id: string, @Query('status') status?: ArticleStatus) {
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
}
}
+97 -3
View File
@@ -8,7 +8,13 @@ import {
IsBoolean,
IsDate,
} from 'class-validator';
import { ArticleStatus, LiveBlogStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
@@ -49,6 +55,26 @@ export class CreateArticleDto {
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateArticleDto {
@@ -92,6 +118,26 @@ export class UpdateArticleDto {
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class FindArticlesDto {
@@ -108,8 +154,8 @@ export class FindArticlesDto {
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsString()
status?: string;
@IsOptional()
@IsString()
@@ -153,6 +199,30 @@ export class CreateLiveBlogDto {
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateLiveBlogDto {
@@ -187,6 +257,30 @@ export class UpdateLiveBlogDto {
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class CreateLiveBlogUpdateDto {
+6 -2
View File
@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
imports: [
TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule),
],
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
+187 -19
View File
@@ -1,7 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities';
import { StrapiService } from './strapi.service';
import {
CreateArticleDto,
UpdateArticleDto,
@@ -10,9 +17,13 @@ import {
@Injectable()
export class ArticlesService {
private readonly logger = new Logger(ArticlesService.name);
constructor(
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {}
async create(dto: CreateArticleDto): Promise<Article> {
@@ -26,21 +37,23 @@ export class ArticlesService {
async findAll(
dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> {
const {
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
const queryBuilder = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.category', 'category')
.where('article.status = :status', { status });
.leftJoinAndSelect('article.category', 'category');
// Handle status filter - can be single value or comma-separated list
if (status) {
if (typeof status === 'string' && status.includes(',')) {
const statuses = status.split(',').map((s) => s.trim());
queryBuilder.where('article.status IN (:...statuses)', { statuses });
} else {
queryBuilder.where('article.status = :status', { status });
}
}
// If no status specified, return all articles (for admin dashboard)
if (category) {
queryBuilder.andWhere('category.slug = :category', { category });
@@ -98,31 +111,186 @@ export class ArticlesService {
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
const article = await this.findOne(id);
const oldStatus = article.status;
Object.assign(article, dto);
return await this.articleRepository.save(article);
const savedArticle = await this.articleRepository.save(article);
// Update Strapi if status changed and article has strapiId
if (
dto.status !== undefined &&
dto.status !== oldStatus &&
article.strapiId
) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
}
async remove(id: string): Promise<void> {
const article = await this.findOne(id);
// Delete from Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
}
}
await this.articleRepository.remove(article);
}
async archive(id: string): Promise<Article> {
const article = await this.findOne(id);
article.status = ArticleStatus.ARCHIVED;
const savedArticle = await this.articleRepository.save(article);
// Update Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
ArticleStatus.ARCHIVED,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
}
async publish(
id: string,
status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> {
const article = await this.findOne(id);
article.status = status;
const savedArticle = await this.articleRepository.save(article);
// Update Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
}
async syncFromStrapi(
strapiId: string,
data: Partial<CreateArticleDto>,
): Promise<Article> {
let article = await this.articleRepository.findOne({ where: { strapiId } });
// Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing article by strapiId
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) {
article = this.articleRepository.create({
if (article) {
// Update existing article
const currentStatus = article.status;
Object.assign(article, data);
// Preserve archived status if article is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
article.status = ArticleStatus.ARCHIVED;
}
return await this.articleRepository.save(article);
} else {
// Create new article
const newArticle = this.articleRepository.create({
strapiId,
...data,
status: data.status || ArticleStatus.DRAFT,
});
} else {
Object.assign(article, data);
return await this.articleRepository.save(newArticle);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingArticle = await this.articleRepository.findOne({
where: { strapiId },
});
if (existingArticle) {
const currentStatus = existingArticle.status;
Object.assign(existingArticle, data);
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
existingArticle.status = ArticleStatus.ARCHIVED;
}
return await this.articleRepository.save(article);
return await this.articleRepository.save(existingArticle);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) {
this.logger.warn(`Article with strapiId ${strapiId} not found`);
return;
}
await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
}
}
+74 -3
View File
@@ -36,6 +36,26 @@ export enum LiveBlogStatus {
ARCHIVED = 'archived',
}
export enum ImagePosition {
TOP = 'top',
LEFT = 'left',
RIGHT = 'right',
NONE = 'none',
}
export enum ImageSize {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large',
}
export enum VideoPosition {
TOP = 'top',
INLINE = 'inline',
BOTTOM = 'bottom',
NONE = 'none',
}
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn('uuid')
@@ -114,6 +134,30 @@ export class Article {
@Column({ default: '' })
featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({
type: 'text',
default: '[]',
@@ -130,7 +174,7 @@ export class Article {
@Column({ default: 0 })
views: number;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@@ -177,7 +221,7 @@ export class LiveBlog {
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@@ -186,6 +230,33 @@ export class LiveBlog {
@Column({ nullable: true })
categoryId: string;
@Column({ default: '' })
featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({ default: 0 })
viewCount: number;
@@ -223,7 +294,7 @@ export class LiveBlogUpdate {
@Column({ nullable: true })
authorId: string;
@Column({ type: 'datetime', nullable: true })
@Column({ type: 'timestamp', nullable: true })
scheduledAt: Date;
@Column({ nullable: true })
@@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
@@ -21,6 +22,7 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { LiveBlogStatus } from './entities';
@Controller('live-blogs')
export class LiveBlogController {
@@ -121,6 +123,16 @@ export class LiveBlogController {
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
}
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.liveBlogService.archive(id);
}
@Patch(':id/publish')
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
}
// SSE endpoint for real-time updates
@Get(':id/stream')
stream(
+3 -1
View File
@@ -1,14 +1,16 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({
imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule),
],
controllers: [LiveBlogController],
providers: [LiveBlogService],
+175 -12
View File
@@ -3,12 +3,15 @@ import {
NotFoundException,
Logger,
OnModuleInit,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
import { StrapiService } from './strapi.service';
import {
CreateLiveBlogDto,
UpdateLiveBlogDto,
@@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit {
@InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {}
onModuleInit() {
@@ -116,13 +121,9 @@ export class LiveBlogService implements OnModuleInit {
} else {
queryBuilder.where('liveBlog.status = :status', { status });
}
} else if (!isPinned) {
// Default to live blogs if no status specified AND not querying for pinned blogs
// Pinned blogs can be either live or ended
queryBuilder.where('liveBlog.status = :status', {
status: LiveBlogStatus.LIVE,
});
}
// If no status specified, return all live blogs (for admin dashboard)
// Note: Pinned blogs query should still work without status filter
if (category) {
queryBuilder.andWhere('category.slug = :category', { category });
@@ -252,6 +253,22 @@ export class LiveBlogService implements OnModuleInit {
blogId: id,
status: dto.status,
});
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
}
return updatedBlog;
@@ -259,9 +276,74 @@ export class LiveBlogService implements OnModuleInit {
async remove(id: string): Promise<void> {
const liveBlog = await this.findOne(id);
// Delete from Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(
`Failed to delete live blog ${id} from Strapi:`,
error,
);
}
}
await this.liveBlogRepository.remove(liveBlog);
}
async archive(id: string): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = LiveBlogStatus.ARCHIVED;
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
LiveBlogStatus.ARCHIVED,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
return savedLiveBlog;
}
async publish(
id: string,
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = status;
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
return savedLiveBlog;
}
// Live Blog Update CRUD operations
async createUpdate(
dto: CreateLiveBlogUpdateDto,
@@ -376,8 +458,19 @@ export class LiveBlogService implements OnModuleInit {
blogId: liveBlogId,
});
// Send periodic keep-alive messages to prevent timeout
const keepAliveInterval = setInterval(() => {
try {
response.write(`: keep-alive\n\n`);
} catch {
// Client disconnected, stop sending keep-alive
clearInterval(keepAliveInterval);
}
}, 15000); // Send keep-alive every 15 seconds
// Handle client disconnect
response.on('close', () => {
clearInterval(keepAliveInterval);
this.sseClients.delete(clientId);
this.logger.log(
`Client ${clientId} disconnected from live blog ${liveBlogId}`,
@@ -414,21 +507,91 @@ export class LiveBlogService implements OnModuleInit {
strapiId: string,
data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> {
let liveBlog = await this.liveBlogRepository.findOne({
// Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing live blog by strapiId
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
liveBlog = this.liveBlogRepository.create({
if (liveBlog) {
// Update existing live blog
const currentStatus = liveBlog.status;
Object.assign(liveBlog, data);
// Preserve archived status if live blog is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
liveBlog.status = LiveBlogStatus.ARCHIVED;
}
return await this.liveBlogRepository.save(liveBlog);
} else {
// Create new live blog
const newLiveBlog = this.liveBlogRepository.create({
strapiId,
...data,
status: data.status || LiveBlogStatus.DRAFT,
});
} else {
Object.assign(liveBlog, data);
return await this.liveBlogRepository.save(newLiveBlog);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingLiveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (existingLiveBlog) {
const currentStatus = existingLiveBlog.status;
Object.assign(existingLiveBlog, data);
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
}
return await this.liveBlogRepository.save(liveBlog);
return await this.liveBlogRepository.save(existingLiveBlog);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(
`Successfully deleted live blog with strapiId: ${strapiId}`,
);
}
// Utility methods
+93 -15
View File
@@ -1,8 +1,13 @@
import { Controller, Post, Body } from '@nestjs/common';
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service';
interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete';
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish';
model: string;
entry: {
documentId: string;
@@ -11,17 +16,102 @@ interface WebhookBody {
@Controller('webhooks/strapi')
export class StrapiController {
private readonly logger = new Logger(StrapiController.name);
constructor(private readonly strapiService: StrapiService) {}
@Post('article')
async handleArticleWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body;
if (model !== 'article') {
this.logger.warn(`Ignored: not an article, model: ${model}`);
return { message: 'Ignored: not an article' };
}
await this.strapiService.handleWebhook(event, entry);
await this.strapiService.handleWebhook(event, {
documentId: entry.documentId,
model,
});
return { message: 'Webhook processed successfully' };
}
@Post('live-blog')
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body;
if (model !== 'live-blog') {
this.logger.warn(`Ignored: not a live blog, model: ${model}`);
return { message: 'Ignored: not a live blog' };
}
await this.strapiService.handleWebhook(event, {
documentId: entry.documentId,
model,
});
return { message: 'Live blog webhook processed successfully' };
}
@Post()
async handleGenericWebhook(@Body() body: unknown) {
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
// Type guard to check if body is an object
if (typeof body !== 'object' || body === null) {
this.logger.warn(`Invalid webhook payload: ${JSON.stringify(body)}`);
return { message: 'Invalid payload' };
}
const bodyObj = body as Record<string, unknown>;
// Try to extract event and model from different possible payload structures
const event = (bodyObj.event || bodyObj.type) as string;
const model = (bodyObj.model || bodyObj.contentType) as string;
const entry = bodyObj.entry || bodyObj.data || bodyObj;
if (!event || !model) {
this.logger.warn(
`Cannot process webhook: missing event or model. Payload: ${JSON.stringify(body)}`,
);
return { message: 'Cannot process: missing event or model' };
}
const entryObj = entry as Record<string, unknown>;
const documentId = (entryObj.documentId ||
entryObj.id ||
(entryObj.document as Record<string, unknown>)?.id) as string;
if (!documentId) {
this.logger.warn(
`Cannot process webhook: missing documentId. Payload: ${JSON.stringify(body)}`,
);
return { message: 'Cannot process: missing documentId' };
}
// Validate event type
const validEvents = [
'entry.create',
'entry.update',
'entry.delete',
'entry.publish',
'entry.unpublish',
];
if (!validEvents.includes(event)) {
this.logger.warn(`Invalid event type: ${event}`);
return { message: 'Invalid event type' };
}
await this.strapiService.handleWebhook(
event as
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
{ documentId, model },
);
return { message: 'Webhook processed successfully' };
}
@@ -31,18 +121,6 @@ export class StrapiController {
return { message: 'Articles sync completed' };
}
@Post('live-blog')
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
const { event, model, entry } = body;
if (model !== 'live-blog') {
return { message: 'Ignored: not a live blog' };
}
await this.strapiService.handleWebhook(event, entry);
return { message: 'Live blog webhook processed successfully' };
}
@Post('sync/live-blogs')
async syncAllLiveBlogs() {
await this.strapiService.syncLiveBlogs();
+6 -2
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller';
@@ -6,7 +6,11 @@ import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module';
@Module({
imports: [HttpModule, ArticlesModule, LiveBlogModule],
imports: [
HttpModule,
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],
+422 -22
View File
@@ -1,11 +1,32 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
interface StrapiImage {
url: string;
alternativeText?: string;
caption?: string;
width?: number;
height?: number;
formats?: Record<string, StrapiImageFormat>;
}
interface StrapiImageFormat {
url: string;
width: number;
height: number;
}
interface StrapiArticle {
id: number;
@@ -17,6 +38,13 @@ interface StrapiArticle {
publishedAt: string | null;
createdAt: string;
updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
}
interface StrapiLiveBlog {
@@ -29,6 +57,13 @@ interface StrapiLiveBlog {
publishedAt: string | null;
createdAt: string;
updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
}
interface StrapiResponse<T> {
@@ -52,7 +87,9 @@ export class StrapiService {
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
@Inject(forwardRef(() => ArticlesService))
private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService,
) {
this.strapiUrl =
@@ -71,13 +108,67 @@ export class StrapiService {
return headers;
}
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiArticle.img?.url) {
imageUrl = strapiArticle.img.url;
} else if (strapiArticle.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiArticle.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
private extractLiveBlogImageUrl(
strapiLiveBlog: StrapiLiveBlog,
): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiLiveBlog.img?.url) {
imageUrl = strapiLiveBlog.img.url;
} else if (strapiLiveBlog.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiLiveBlog.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
async syncArticles(): Promise<void> {
try {
this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles`,
`${this.strapiUrl}/api/articles?populate=*`,
{
headers: this.getHeaders(),
},
@@ -88,6 +179,8 @@ export class StrapiService {
let syncedCount = 0;
for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
@@ -97,6 +190,14 @@ export class StrapiService {
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
tags: [],
featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
};
await this.articlesService.syncFromStrapi(
@@ -115,13 +216,23 @@ export class StrapiService {
}
}
async syncSingleArticle(strapiId: string): Promise<void> {
async syncSingleArticle(
strapiId: string,
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}`);
this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}`,
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
@@ -129,23 +240,71 @@ export class StrapiService {
);
const strapiArticle = response.data.data;
// Determine status based on publishedAt and event type
let status: ArticleStatus;
if (event === 'entry.unpublish') {
// If it's an unpublish event, always mark as draft
status = ArticleStatus.DRAFT;
} else if (strapiArticle.publishedAt) {
// If publishedAt exists, it's published
status = ArticleStatus.PUBLISHED;
} else {
// Otherwise it's a draft
status = ArticleStatus.DRAFT;
}
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
content: strapiArticle.content,
slug: strapiArticle.slug,
status: strapiArticle.publishedAt
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
status,
tags: [],
featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
};
await this.articlesService.syncFromStrapi(
strapiArticle.documentId,
articleData,
);
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
} catch (error) {
this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft
const axiosError = error as { response?: { status: number } };
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log(
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the article status to draft directly
try {
const articleData: Partial<CreateArticleDto> = {
status: ArticleStatus.DRAFT,
};
await this.articlesService.syncFromStrapi(strapiId, articleData);
this.logger.log(
`Marked article ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) {
this.logger.error(
`Failed to mark article ${strapiId} as draft:`,
syncError,
);
throw syncError;
}
} else {
this.logger.error(
`Failed to sync article ${strapiId} from Strapi`,
error,
@@ -153,6 +312,7 @@ export class StrapiService {
throw error;
}
}
}
async syncLiveBlogs(): Promise<void> {
try {
@@ -160,7 +320,7 @@ export class StrapiService {
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs`,
`${this.strapiUrl}/api/live-blogs?populate=*`,
{
headers: this.getHeaders(),
},
@@ -171,11 +331,21 @@ export class StrapiService {
let syncedCount = 0;
for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
};
await this.liveBlogService.syncFromStrapi(
@@ -194,13 +364,23 @@ export class StrapiService {
}
}
async syncSingleLiveBlog(strapiId: string): Promise<void> {
async syncSingleLiveBlog(
strapiId: string,
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}`);
this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
@@ -208,19 +388,65 @@ export class StrapiService {
);
const strapiLiveBlog = response.data.data;
// For live blogs, we use the status from Strapi directly
// but we might want to handle unpublish events differently
let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status);
// If it's an unpublish event, set to draft
if (event === 'entry.unpublish') {
status = LiveBlogStatus.DRAFT;
}
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
status,
featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
};
await this.liveBlogService.syncFromStrapi(
strapiLiveBlog.documentId,
liveBlogData,
);
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title}`);
} catch (error) {
this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft
const axiosError = error as { response?: { status: number } };
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log(
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the live blog status to draft directly
try {
const liveBlogData: Partial<CreateLiveBlogDto> = {
status: LiveBlogStatus.DRAFT,
};
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
this.logger.log(
`Marked live blog ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) {
this.logger.error(
`Failed to mark live blog ${strapiId} as draft:`,
syncError,
);
throw syncError;
}
} else {
this.logger.error(
`Failed to sync live blog ${strapiId} from Strapi`,
error,
@@ -228,6 +454,7 @@ export class StrapiService {
throw error;
}
}
}
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
switch (status) {
@@ -245,7 +472,12 @@ export class StrapiService {
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete',
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(
@@ -253,17 +485,185 @@ export class StrapiService {
);
if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`);
this.logger.log(
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
);
if (data.model === 'article') {
await this.articlesService.removeByStrapiId(data.documentId);
} else if (data.model === 'live-blog') {
await this.liveBlogService.removeByStrapiId(data.documentId);
} else {
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
}
return;
}
// Route to appropriate sync method based on model
if (data.model === 'article') {
await this.syncSingleArticle(data.documentId);
await this.syncSingleArticle(data.documentId, event);
} else if (data.model === 'live-blog') {
await this.syncSingleLiveBlog(data.documentId);
await this.syncSingleLiveBlog(data.documentId, event);
} else {
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
}
}
async updateArticleStatusInStrapi(
strapiId: string,
status: ArticleStatus,
): Promise<void> {
try {
this.logger.log(
`Updating article ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case ArticleStatus.DRAFT:
strapiStatus = 'draft';
break;
case ArticleStatus.PUBLISHED:
strapiStatus = 'published';
break;
case ArticleStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === ArticleStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update article ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async updateLiveBlogStatusInStrapi(
strapiId: string,
status: LiveBlogStatus,
): Promise<void> {
try {
this.logger.log(
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case LiveBlogStatus.DRAFT:
strapiStatus = 'draft';
break;
case LiveBlogStatus.LIVE:
strapiStatus = 'live';
break;
case LiveBlogStatus.ENDED:
strapiStatus = 'ended';
break;
case LiveBlogStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === LiveBlogStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update live blog ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting article ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
headers: this.getHeaders(),
}),
);
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete article ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ headers: this.getHeaders() },
),
);
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete live blog ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
# CMS Dockerfile for Placebo.mk Strapi CMS
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY package-lock.json* ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build Strapi
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Install SQLite for development (will use PostgreSQL in production)
RUN apk add --no-cache sqlite
# Copy built application from builder stage
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/public ./public
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Copy environment configuration
COPY --chown=nodejs:nodejs .env.example .env
# Create data directory for SQLite
RUN mkdir -p /app/.tmp && \
chown -R nodejs:nodejs /app/.tmp
# Switch to non-root user
USER nodejs
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})"
# Expose port
EXPOSE 1337
# Start Strapi
CMD ["npm", "run", "start"]
+35
View File
@@ -0,0 +1,35 @@
# CMS Development Dockerfile for Placebo.mk Strapi CMS
FROM node:20-slim
WORKDIR /app
# Install Python and build tools for any native modules
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
# Fix permissions before npm install
RUN chown -R node:node /app
# Switch to non-root user for npm install
USER node
# Clear npm cache and install dependencies
RUN npm cache clean --force && \
npm install
# Copy source code as node user
COPY --chown=node:node . .
# Expose port
EXPOSE 1337
# Start development server
CMD ["npm", "run", "develop"]
+1 -1
View File
@@ -18,7 +18,7 @@
"@strapi/plugin-cloud": "5.33.0",
"@strapi/plugin-users-permissions": "5.33.0",
"@strapi/strapi": "5.33.0",
"better-sqlite3": "12.4.1",
"pg": "^8.13.3",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.0.0",
@@ -29,6 +29,40 @@
},
"author": {
"type": "string"
},
"img": {
"type": "media",
"multiple": false,
"allowedTypes": [
"images",
"files",
"videos",
"audios"
]
},
"imagePosition": {
"type": "enumeration",
"enum": ["top", "left", "right", "none"],
"default": "top"
},
"imageSize": {
"type": "enumeration",
"enum": ["small", "medium", "large"],
"default": "medium"
},
"videoUrl": {
"type": "string",
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
"default": ""
},
"videoPosition": {
"type": "enumeration",
"enum": ["top", "inline", "bottom", "none"],
"default": "inline"
},
"videoCaption": {
"type": "string",
"default": ""
}
}
}
+13
View File
@@ -446,6 +446,13 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
imagePosition: Schema.Attribute.Enumeration<
['top', 'left', 'right', 'none']
> &
Schema.Attribute.DefaultTo<'top'>;
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
Schema.Attribute.DefaultTo<'medium'>;
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
@@ -461,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
videoPosition: Schema.Attribute.Enumeration<
['top', 'inline', 'bottom', 'none']
> &
Schema.Attribute.DefaultTo<'inline'>;
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
};
}
+115
View File
@@ -0,0 +1,115 @@
version: '3.8'
services:
# PostgreSQL database for development
postgres:
image: postgres:16-alpine
container_name: placebo-postgres-dev
environment:
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password
POSTGRES_DB: placebo_backend_db
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
ports:
- "5432:5432"
networks:
- placebo-network-dev
# Backend API (NestJS) - Development with hot reload
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: placebo-backend-dev
env_file:
- ./backend/.env # Use the .env file for configuration
environment:
# Only override if needed, most config is in .env file
NODE_ENV: development
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
- ./backend/src:/app/src
- ./backend/package.json:/app/package.json
- ./backend/package-lock.json:/app/package-lock.json
- ./backend/.env:/app/.env # Mount .env file into container
command: npm run dev:docker
networks:
- placebo-network-dev
# Frontend (TanStack React) - Development with hot reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: placebo-frontend-dev
env_file:
- ./frontend/.env # Use the .env file for configuration
environment:
NODE_ENV: development
ports:
- "5173:5173"
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- ./frontend/package.json:/app/package.json
- ./frontend/package-lock.json:/app/package-lock.json
- ./frontend/index.html:/app/index.html
- ./frontend/vite.config.ts:/app/vite.config.ts
- ./frontend/tsconfig.json:/app/tsconfig.json
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
- ./frontend/tailwind.config.js:/app/tailwind.config.js
- ./frontend/.env:/app/.env
command: npm run dev:docker
networks:
- placebo-network-dev
# CMS (Strapi) - Development with hot reload
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile.dev
container_name: placebo-cms-dev
environment:
NODE_ENV: development
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: placebo_cms_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_SSL: "false"
JWT_SECRET: dev-jwt-secret
ADMIN_JWT_SECRET: dev-admin-jwt-secret
APP_KEYS: dev-app-keys
API_TOKEN_SALT: dev-api-token-salt
TRANSFER_TOKEN_SALT: dev-transfer-token-salt
CORS_ORIGIN: http://localhost:5173
PORT: 1337
ports:
- "1337:1337"
depends_on:
- postgres
volumes:
- ./cms/cms/src:/app/src
- ./cms/cms/config:/app/config
- ./cms/cms/public:/app/public
- ./cms/cms/package.json:/app/package.json
- ./cms/cms/package-lock.json:/app/package-lock.json
command: npm run develop
networks:
- placebo-network-dev
volumes:
postgres_data_dev:
driver: local
networks:
placebo-network-dev:
driver: bridge
+151
View File
@@ -0,0 +1,151 @@
version: '3.8'
services:
# PostgreSQL database for production
postgres:
image: postgres:16-alpine
container_name: placebo-postgres
environment:
POSTGRES_DB: placebo_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"]
interval: 10s
timeout: 5s
retries: 5
networks:
- placebo-network
# Backend API (NestJS)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: placebo-backend
environment:
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_db
DATABASE_SYNCHRONIZE: "false"
DATABASE_LOGGING: "false"
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
JWT_EXPIRATION: 3600
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
PORT: 3000
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend/.env:/app/.env:ro
- ./backend/uploads:/app/uploads
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) throw new Error()})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-network
# Frontend (TanStack React)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: placebo-frontend
environment:
NODE_ENV: production
VITE_API_URL: http://localhost:3000
VITE_CMS_URL: http://localhost:1337
ports:
- "3001:80"
depends_on:
- backend
volumes:
- ./frontend/.env:/app/.env:ro
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- placebo-network
# CMS (Strapi)
cms:
build:
context: ./cms/cms
dockerfile: Dockerfile
container_name: placebo-cms
environment:
NODE_ENV: production
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: placebo_db
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_SSL: "false"
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-your-admin-jwt-secret-key-change-in-production}
APP_KEYS: ${APP_KEYS:-your-app-keys-change-in-production}
API_TOKEN_SALT: ${API_TOKEN_SALT:-your-api-token-salt-change-in-production}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT:-your-transfer-token-salt-change-in-production}
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
PORT: 1337
ports:
- "1337:1337"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./cms/cms/.env:/app/.env:ro
- ./cms/cms/public/uploads:/app/public/uploads
- ./cms/cms/.tmp:/app/.tmp
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- placebo-network
# Nginx reverse proxy (for production)
nginx:
image: nginx:alpine
container_name: placebo-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./frontend/dist:/usr/share/nginx/html:ro
depends_on:
- frontend
- backend
- cms
networks:
- placebo-network
volumes:
postgres_data:
driver: local
networks:
placebo-network:
driver: bridge
+12
View File
@@ -1 +1,13 @@
# Frontend Environment Configuration
# Copy to .env and adjust for your setup
# ===== DOCKER MODE (frontend in container) =====
# Use when frontend runs in Docker container
# VITE_API_URL=http://backend:3000/api/v1
# ===== LOCAL MODE (frontend runs locally) =====
# Use when frontend runs locally with npm run dev
VITE_API_URL=http://localhost:3000/api/v1
# ===== COMMON =====
VITE_CMS_URL=http://localhost:1337
+45
View File
@@ -0,0 +1,45 @@
# Frontend Dockerfile for Placebo.mk TanStack React App
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY package-lock.json* ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Create non-root user
RUN addgroup -g 1001 -S nginx && \
adduser -S nginx -u 1001 -G nginx
# Copy built application from builder stage
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Switch to non-root user
USER nginx
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+28
View File
@@ -0,0 +1,28 @@
# Frontend Development Dockerfile for Placebo.mk TanStack React App
FROM node:20-alpine
WORKDIR /app
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
# Clear npm cache and install dependencies
RUN npm cache clean --force && \
npm install
# Copy source code
COPY . .
# Fix permissions - use node user that exists in base image
RUN chown -R node:node /app
# Switch to non-root user that exists in base image
USER node
# Expose port
EXPOSE 5173
# Start development server with host flag
CMD ["npm", "run", "dev", "--", "--host"]
+107
View File
@@ -0,0 +1,107 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_disable "MSIE [1-6]\.";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Frontend server
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers for frontend
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy
location /api/ {
proxy_pass http://backend:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
# CMS proxy
location /cms/ {
proxy_pass http://cms:1337/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
+1482 -22
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -12,7 +12,10 @@
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
"test:coverage": "vitest --coverage",
"dev:docker": "vite",
"dev:local": "cp -f .env.local .env && vite",
"dev:reset-env": "cp -f .env.docker .env"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
@@ -23,8 +26,11 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+2 -2
View File
@@ -24,7 +24,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
key={`${article.id}-${index}`}
to={`/articles/${article.id}`}
to={`/articles/${article.id}` as any}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
>
{article.title || 'No title'}
@@ -34,7 +34,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}`}
to={`/articles/${article.id}` as any}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
>
{article.title || 'No title'}
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import {
useLiveBlog,
useDeleteLiveBlogUpdate,
@@ -23,7 +23,7 @@ export function LiveBlogTicker({
const [scrollPosition, setScrollPosition] = useState(0);
const tickerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number>();
const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0);
@@ -55,7 +55,7 @@ export function LiveBlogTicker({
// Reset when scrolled past content
if (newPos > totalWidth) {
newPos = -tickerRef.current?.offsetWidth || 0;
newPos = -(tickerRef.current?.offsetWidth || 0);
}
return newPos;
@@ -1,4 +1,4 @@
import React from 'react';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLiveBlogStream } from '@/hooks/useLiveBlogStream';
import { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
@@ -1,9 +1,9 @@
import React from 'react';
import { Link } from '@tanstack/react-router';
import { usePinnedLiveBlogs } from '@/queries/live-blogs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { LiveBlog } from '@/lib/api';
import {
Clock,
MessageSquare,
@@ -49,11 +49,49 @@ export function PinnedLiveBlogSidebar({
);
}
if (error || !pinnedBlogs || pinnedBlogs.length === 0) {
return null; // Don't show sidebar if no pinned blogs
if (isLoading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Live Coverage
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse space-y-2">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
<div className="h-2 bg-muted rounded w-1/4"></div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
const displayBlogs = pinnedBlogs.slice(0, maxItems);
if (error) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Live Coverage
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
Error loading live coverage
</p>
</CardContent>
</Card>
);
}
const displayBlogs = (pinnedBlogs || []).slice(0, maxItems);
const getStatusColor = (status: string) => {
switch (status) {
@@ -100,12 +138,23 @@ export function PinnedLiveBlogSidebar({
Live Coverage
</CardTitle>
<Badge variant="outline" className="text-xs">
{pinnedBlogs.length} pinned
{(pinnedBlogs || []).length} pinned
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
{displayBlogs.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">
No pinned live blogs at the moment
</p>
<p className="text-xs text-muted-foreground mt-1">
Check back later for live coverage
</p>
</div>
) : (
<>
<div className="space-y-4">
{displayBlogs.map((blog) => {
const latestUpdate = getLatestUpdate(blog);
@@ -115,27 +164,23 @@ export function PinnedLiveBlogSidebar({
key={blog.id}
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
className="block group"
className="block p-3 rounded-lg border hover:bg-accent/50 transition-colors group"
>
<div className="p-3 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors">
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors line-clamp-2">
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
{blog.title}
</h3>
</h4>
{blog.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
<p className="text-xs text-muted-foreground line-clamp-2">
{blog.description}
</p>
)}
</div>
<Badge
className={getStatusColor(blog.status)}
className={`${getStatusColor(blog.status)} text-xs`}
variant="outline"
size="sm"
>
<div className="flex items-center gap-1">
{getStatusIcon(blog.status)}
@@ -156,23 +201,14 @@ export function PinnedLiveBlogSidebar({
)}
{/* Stats */}
<div className="flex items-center justify-between mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<span>{blog.viewCount}</span>
</div>
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>{blog.updates?.length || 0}</span>
</div>
</div>
<div className="flex items-center gap-1 text-primary">
<span className="text-xs">View live</span>
<ChevronRight className="w-3 h-3" />
<span>{blog.updates?.length || 0} updates</span>
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<span>{blog.viewCount} views</span>
</div>
</div>
</Link>
@@ -180,18 +216,21 @@ export function PinnedLiveBlogSidebar({
})}
</div>
{pinnedBlogs.length > maxItems && (
<div className="mt-4 pt-3 border-t">
<Link
to="/live-blogs"
className="w-full"
{/* View all button */}
{(pinnedBlogs || []).length > maxItems && (
<Button
variant="ghost"
size="sm"
className="w-full mt-4"
asChild
>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span>View all pinned blogs</span>
<ChevronRight className="w-4 h-4" />
</Button>
<Link to="/live-blogs">
View all pinned blogs
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
</div>
</Button>
)}
</>
)}
</CardContent>
</Card>
@@ -0,0 +1,450 @@
import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() {
// State for confirmation dialog and filters
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{
type: 'article' | 'liveBlog';
id: string;
title: string;
} | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
limit: 50,
status: showArchived ? 'archived' : 'draft,live,ended'
});
const { data: articlesData, isLoading: loadingArticles } = useArticles({
limit: 50,
status: showArchived ? 'archived' : 'draft,published'
});
const deleteArticleMutation = useDeleteArticle();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const archiveArticleMutation = useArchiveArticle();
const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog();
const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('delete');
setShowConfirmDialog(true);
};
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('archive');
setShowConfirmDialog(true);
};
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
setIsProcessing(true);
try {
if (type === 'article') {
await publishArticleMutation.mutateAsync({ id, status: 'published' });
} else {
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
}
} catch (error) {
console.error('Failed to publish:', error);
} finally {
setIsProcessing(false);
}
};
const handleConfirmAction = async () => {
if (!itemToDelete) return;
setIsProcessing(true);
try {
if (dialogType === 'delete') {
if (itemToDelete.type === 'article') {
await deleteArticleMutation.mutateAsync(itemToDelete.id);
} else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
}
} else { // archive
if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else {
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
}
}
setShowConfirmDialog(false);
setItemToDelete(null);
} catch (error) {
console.error(`Failed to ${dialogType}:`, error);
} finally {
setIsProcessing(false);
}
};
const handleCancelAction = () => {
setShowConfirmDialog(false);
setItemToDelete(null);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'published':
case 'live':
return 'bg-green-100 text-green-800 border-green-200';
case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'archived':
case 'ended':
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'published': return 'Објавено';
case 'draft': return 'Нацрт';
case 'archived': return 'Архивирано';
case 'live': return 'Во живо';
case 'ended': return 'Завршено';
default: return status;
}
};
return (
<div className="py-8 space-y-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
<p className="text-muted-foreground">
Управување со сите написи и live блогови
</p>
</div>
<div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild>
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Live Blogs Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
<Badge variant="outline" className="ml-2">
{filteredLiveBlogs.length || 0}
</Badge>
</CardTitle>
<CardDescription>
Сите live блогови со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingLiveBlogs ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : filteredLiveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filteredLiveBlogs.map((blog) => (
<div
key={blog.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline"
>
{blog.title}
</Link>
<Badge variant="outline" className={getStatusColor(blog.status)}>
{getStatusText(blog.status)}
</Badge>
{blog.isPinned && (
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
Закачено
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Слаг: {blog.slug}</span>
<span>•</span>
<span>
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
</span>
<span>•</span>
<span>Прегледи: {blog.viewCount}</span>
</div>
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
Преглед
</Link>
</Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Articles Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
<Badge variant="outline" className="ml-2">
{filteredArticles.length || 0}
</Badge>
</CardTitle>
<CardDescription>
Сите написи со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingArticles ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : filteredArticles.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filteredArticles.map((article) => (
<div
key={article.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline"
>
{article.title}
</Link>
<Badge variant="outline" className={getStatusColor(article.status)}>
{getStatusText(article.status)}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Слаг: {article.slug}</span>
<span>•</span>
<span>
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
</span>
<span>•</span>
<span>Прегледи: {article.views}</span>
</div>
{article.excerpt && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{article.excerpt}
</p>
)}
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
Преглед
</Link>
</Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-sm text-muted-foreground">Активни live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-sm text-muted-foreground">Објавени написи</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</CardContent>
</Card>
</div>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3>
<p className="text-muted-foreground mb-4">
{dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи
</Button>
<Button
variant={dialogType === 'delete' ? 'destructive' : 'default'}
onClick={handleConfirmAction}
disabled={isProcessing}
>
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
@@ -65,16 +69,101 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)}
</div>
{data.featuredImage && (
{data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${
data.imagePosition === 'top'
? 'w-full mb-8'
: data.imagePosition === 'left'
? 'float-none md:float-left mr-0 md:mr-6'
: 'float-none md:float-right ml-0 md:ml-6'
}`}>
<img
src={data.featuredImage}
alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
className={`rounded-xl object-cover ${
data.imagePosition === 'top'
? data.imageSize === 'small'
? 'h-32'
: data.imageSize === 'medium'
? 'h-48'
: 'h-64 md:h-96'
: data.imageSize === 'small'
? 'w-full md:w-48 h-32'
: data.imageSize === 'medium'
? 'w-full md:w-64 h-48'
: 'w-full md:w-96 h-64'
}`}
onError={(e) => {
console.error('Failed to load image:', data.featuredImage, e);
e.currentTarget.style.display = 'none';
// Show fallback
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/>
<div
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
style={{ display: 'none' }}
>
<span>Image not available</span>
</div>
</div>
)}
{/* Video rendering */}
{data.videoUrl && data.videoPosition !== 'none' && (
<div className={getVideoPositionClasses(data.videoPosition)}>
<YouTubeEmbed
url={data.videoUrl}
title={data.title}
caption={data.videoCaption}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
)}
<div className="prose prose-slate max-w-none">
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
<div className="text-lg leading-relaxed mb-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: (props) => (
<img
{...props}
className="max-w-full h-auto rounded-lg my-4"
alt={props.alt || 'Article image'}
/>
),
a: (props) => {
// Check if the link is a YouTube URL
const videoId = extractYouTubeVideoId(props.href || '');
if (videoId) {
return (
<div className="my-6">
<YouTubeEmbed
url={props.href || ''}
title={props.title || 'YouTube video'}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
);
}
// Regular link
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
}
}}
>
{data.content}
</ReactMarkdown>
</div>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
@@ -35,7 +35,7 @@ export function ArticlesComponent() {
{data?.data.map((article) => (
<Link
key={article.id}
to={`/articles/${article.id}`}
to={`/articles/${article.id}` as any}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
@@ -38,7 +38,7 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => (
<Link
key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}`}
to={`/live-blogs/${liveBlog.slug}` as any}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<div className="flex items-start justify-between mb-4">
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
interface YouTubeEmbedProps {
/** YouTube video URL or video ID */
url: string;
/** Video title for accessibility */
title?: string;
/** Optional caption below the video */
caption?: string;
/** Additional CSS classes */
className?: string;
/** Autoplay the video (muted on mobile) */
autoplay?: boolean;
/** Show video controls */
controls?: boolean;
/** Reduce YouTube branding */
modestbranding?: boolean;
/** Show related videos at the end */
showRelated?: boolean;
/** Start time in seconds */
startTime?: number;
/** End time in seconds */
endTime?: number;
/** Show loading state */
showLoading?: boolean;
}
export function YouTubeEmbed({
url,
title = 'YouTube video',
caption,
className,
autoplay = false,
controls = true,
modestbranding = true,
showRelated = false,
startTime,
endTime,
showLoading = true,
}: YouTubeEmbedProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
// Extract video ID from URL
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
return (
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
<p className="text-destructive text-sm">
Invalid YouTube URL: {url}
</p>
</div>
);
}
// Generate embed URL with options
const embedUrl = generateYouTubeEmbedUrl(videoId, {
autoplay,
controls,
modestbranding,
rel: showRelated,
playsinline: true,
start: startTime,
end: endTime,
});
return (
<div className={cn('w-full', className)}>
{/* Video container with aspect ratio */}
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
{/* Loading state */}
{showLoading && !isLoaded && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{/* Error state */}
{hasError && (
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
<div className="text-destructive mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-destructive font-medium">Video failed to load</p>
<p className="text-muted-foreground text-sm mt-1">
The YouTube video could not be loaded. Please check the URL or try again later.
</p>
</div>
)}
{/* YouTube iframe */}
{!hasError && (
<iframe
className={cn(
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
!isLoaded && 'opacity-0'
)}
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
)}
</div>
{/* Caption */}
{caption && (
<div className="mt-2 text-sm text-muted-foreground text-center">
{caption}
</div>
)}
</div>
);
}
+15 -8
View File
@@ -16,21 +16,28 @@ export function useLiveBlogStream(
liveBlogId: string,
options: LiveBlogStreamOptions = {}
) {
const defaultOptions: LiveBlogStreamOptions = {
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 10,
};
const mergedOptions = { ...defaultOptions, ...options };
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastEventIdRef = useRef<string | null>(null);
const optionsRef = useRef(options);
const optionsRef = useRef(mergedOptions);
const reconnectAttemptsRef = useRef(reconnectAttempts);
// Update refs when props change
useEffect(() => {
optionsRef.current = options;
}, [options]);
optionsRef.current = mergedOptions;
}, [mergedOptions]);
useEffect(() => {
reconnectAttemptsRef.current = reconnectAttempts;
@@ -59,7 +66,7 @@ export function useLiveBlogStream(
eventSourceRef.current.close();
}
const url = new URL(`${import.meta.env.VITE_API_URL}/api/v1/live-blogs/${liveBlogId}/stream`, window.location.origin);
const url = new URL(`${import.meta.env.VITE_API_URL}/live-blogs/${liveBlogId}/stream`, window.location.origin);
if (lastEventIdRef.current) {
url.searchParams.set('last-event-id', lastEventIdRef.current);
@@ -117,14 +124,14 @@ export function useLiveBlogStream(
setConnectionError('Connection to live blog lost');
// Attempt reconnection if enabled and within limits
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 5)) {
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 10)) {
const nextAttempt = reconnectAttemptsRef.current + 1;
setReconnectAttempts(nextAttempt);
reconnectTimeoutRef.current = setTimeout(() => {
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 5})`);
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
createConnection();
}, optionsRef.current.reconnectInterval || 3000);
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 5)) {
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
setConnectionError('Failed to reconnect after multiple attempts');
}
};
+132 -2
View File
@@ -1,5 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
// Debug logging
console.log('API_BASE_URL:', API_BASE_URL);
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
export interface Article {
id: string;
title: string;
@@ -7,6 +11,11 @@ export interface Article {
excerpt: string | null;
slug: string;
featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
tags: string[];
status: 'draft' | 'published' | 'archived';
views: number;
@@ -41,12 +50,48 @@ export interface FindArticlesParams {
category?: string;
author?: string;
tag?: string;
status?: 'draft' | 'published' | 'archived';
status?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateArticleDto {
title: string;
content: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export interface UpdateArticleDto {
title?: string;
content?: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
const searchParams = new URLSearchParams();
@@ -93,6 +138,63 @@ export async function fetchArticleById(id: string): Promise<Article> {
return response.json();
}
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create article');
}
return response.json();
}
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update article');
}
return response.json();
}
export async function deleteArticle(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete article');
}
}
export async function archiveArticle(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive article');
}
return response.json();
}
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish article');
}
return response.json();
}
// Live Blog Types
export interface LiveBlogUpdate {
id: string;
@@ -118,9 +220,16 @@ export interface LiveBlog {
slug: string;
description: string | null;
status: 'draft' | 'live' | 'ended' | 'archived';
isPinned: boolean;
strapiId: string | null;
authorId: string | null;
categoryId: string | null;
featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
viewCount: number;
author?: {
id: string;
@@ -157,7 +266,7 @@ export interface LiveBlogUpdatesResponse {
export interface FindLiveBlogsParams {
category?: string;
author?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
status?: string;
search?: string;
page?: number;
limit?: number;
@@ -194,6 +303,7 @@ export interface UpdateLiveBlogDto {
slug?: string;
description?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
isPinned?: boolean;
authorId?: string;
categoryId?: string;
strapiId?: string;
@@ -355,3 +465,23 @@ export async function deleteLiveBlog(id: string): Promise<void> {
throw new Error('Failed to delete live blog');
}
}
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive live blog');
}
return response.json();
}
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish live blog');
}
return response.json();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Video utilities for handling YouTube embeds and video URLs
*/
export type VideoPosition = 'top' | 'inline' | 'bottom' | 'none';
export type VideoPlatform = 'youtube' | 'vimeo' | 'dailymotion' | 'twitch' | 'uploaded' | 'external';
/**
* Extract YouTube video ID from various URL formats
* Supported formats:
* - https://www.youtube.com/watch?v=VIDEO_ID
* - https://youtu.be/VIDEO_ID
* - https://www.youtube.com/embed/VIDEO_ID
* - https://www.youtube.com/v/VIDEO_ID
* - https://www.youtube.com/shorts/VIDEO_ID
*/
export function extractYouTubeVideoId(url: string): string | null {
if (!url) return null;
const patterns = [
// Standard watch URL
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
// Just the video ID
/^([a-zA-Z0-9_-]{11})$/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* Check if a URL is a valid YouTube URL
*/
export function isValidYouTubeUrl(url: string): boolean {
return extractYouTubeVideoId(url) !== null;
}
/**
* Get YouTube thumbnail URL for a video ID
* Quality options: default, mq (medium), hq (high), sd (standard), maxres (maximum resolution)
*/
export function getYouTubeThumbnail(
videoId: string,
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
): string {
const qualityMap = {
default: 'default.jpg',
mq: 'mqdefault.jpg',
hq: 'hqdefault.jpg',
sd: 'sddefault.jpg',
maxres: 'maxresdefault.jpg',
};
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}`;
}
/**
* Detect video platform from URL
*/
export function detectVideoPlatform(url: string): VideoPlatform {
if (!url) return 'external';
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('vimeo.com')) return 'vimeo';
if (url.includes('dailymotion.com')) return 'dailymotion';
if (url.includes('twitch.tv')) return 'twitch';
if (url.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)$/i)) return 'uploaded';
return 'external';
}
/**
* Generate YouTube embed URL with privacy-enhanced mode and parameters
*/
export function generateYouTubeEmbedUrl(
videoId: string,
options: {
autoplay?: boolean;
controls?: boolean;
modestbranding?: boolean;
rel?: boolean;
playsinline?: boolean;
start?: number;
end?: number;
} = {}
): string {
const {
autoplay = false,
controls = true,
modestbranding = true,
rel = false,
playsinline = true,
start,
end,
} = options;
const params = new URLSearchParams({
autoplay: autoplay ? '1' : '0',
controls: controls ? '1' : '0',
modestbranding: modestbranding ? '1' : '0',
rel: rel ? '1' : '0',
playsinline: playsinline ? '1' : '0',
});
if (start !== undefined) params.set('start', start.toString());
if (end !== undefined) params.set('end', end.toString());
// Use privacy-enhanced mode (youtube-nocookie.com)
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
}
/**
* Get CSS classes for video positioning
*/
export function getVideoPositionClasses(position: VideoPosition): string {
switch (position) {
case 'top':
return 'mb-8';
case 'inline':
return 'my-6';
case 'bottom':
return 'mt-8';
case 'none':
return '';
default:
return 'my-6';
}
}
/**
* Validate video URL
*/
export function validateVideoUrl(url: string): { isValid: boolean; platform: VideoPlatform; error?: string } {
if (!url) {
return { isValid: false, platform: 'external', error: 'Video URL is required' };
}
try {
new URL(url);
} catch {
return { isValid: false, platform: 'external', error: 'Invalid URL format' };
}
const platform = detectVideoPlatform(url);
if (platform === 'youtube' && !isValidYouTubeUrl(url)) {
return { isValid: false, platform: 'youtube', error: 'Invalid YouTube URL' };
}
return { isValid: true, platform };
}
+66 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function useArticles(params: api.FindArticlesParams = {}) {
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
enabled: !!id,
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createArticle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
}
export function useUpdateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
api.updateArticle(id, dto),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function useDeleteArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteArticle,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
// Also invalidate any specific article queries
queryClient.removeQueries({ queryKey: ['article', variables] });
},
});
}
export function useArchiveArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.archiveArticle,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function usePublishArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
api.publishArticle(id, status),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
+31 -3
View File
@@ -32,6 +32,7 @@ export function useLiveBlogUpdates(liveBlogId: string, page = 1, limit = 50) {
queryKey: ['liveBlogUpdates', liveBlogId, page, limit],
queryFn: () => api.fetchLiveBlogUpdates(liveBlogId, page, limit),
enabled: !!liveBlogId,
refetchInterval: 10000, // Poll every 10 seconds as fallback
});
}
@@ -66,7 +67,7 @@ export function useCreateLiveBlogUpdate() {
return useMutation({
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
api.createLiveBlogUpdate(liveBlogId, dto),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
@@ -83,7 +84,7 @@ export function useUpdateLiveBlogUpdate() {
updateId: string;
dto: api.UpdateLiveBlogUpdateDto
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
@@ -97,7 +98,7 @@ export function useDeleteLiveBlogUpdate() {
return useMutation({
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
api.deleteLiveBlogUpdate(liveBlogId, updateId),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
@@ -155,3 +156,30 @@ export function useDeleteLiveBlog() {
},
});
}
export function useArchiveLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.archiveLiveBlog(id),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
export function usePublishLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
api.publishLiveBlog(id, status),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
+11
View File
@@ -6,6 +6,7 @@ import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
import './styles.css'
@@ -36,6 +37,9 @@ const rootRoute = createRootRoute({
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
Live
</Link>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
Admin
</Link>
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
+ New Live Blog
</Link>
@@ -247,6 +251,12 @@ const createLiveBlogRoute = createRoute({
component: CreateLiveBlogComponent,
})
const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: AdminDashboardComponent,
})
const routeTree = rootRoute.addChildren([
indexRoute,
articlesRoute,
@@ -255,6 +265,7 @@ const routeTree = rootRoute.addChildren([
liveBlogDetailRoute,
liveBlogAdminRoute,
createLiveBlogRoute,
adminDashboardRoute,
])
export const router = createRouter({ routeTree })
+5
View File
@@ -14,4 +14,9 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true, // Listen on all addresses
port: 5173,
strictPort: true,
},
})
+29294
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "placebo-mk",
"version": "1.0.0",
"private": true,
"description": "Placebo.mk - Macedonian news site with sarcastic tone",
"scripts": {
"docker:up": "docker-compose -f docker-compose.dev.yml up",
"docker:down": "docker-compose -f docker-compose.dev.yml down",
"docker:build": "docker-compose -f docker-compose.dev.yml build",
"docker:logs": "docker-compose -f docker-compose.dev.yml logs -f",
"docker:restart": "docker-compose -f docker-compose.dev.yml restart",
"docker:ps": "docker-compose -f docker-compose.dev.yml ps",
"dev:docker": "npm run docker:up",
"dev:local": "echo 'Starting local development...' && concurrently \"npm run dev:backend:local\" \"npm run dev:frontend:local\"",
"dev:backend:local": "cd backend && npm run dev:local",
"dev:frontend:local": "cd frontend && npm run dev:local",
"dev:backend:docker": "cd backend && npm run dev:docker",
"dev:frontend:docker": "cd frontend && npm run dev:docker",
"reset:env": "cd backend && npm run dev:reset-env && cd ../frontend && npm run dev:reset-env",
"lint": "concurrently \"cd backend && npm run lint\" \"cd frontend && npm run lint\"",
"lint:fix": "concurrently \"cd backend && npm run lint:fix\" \"cd frontend && npm run lint:fix\"",
"type-check": "concurrently \"cd backend && npm run type-check\" \"cd frontend && npm run type-check\"",
"db:backup": "./scripts/db-backup.sh",
"db:restore": "./scripts/db-restore.sh",
"db:reset": "./scripts/db-reset.sh"
},
"devDependencies": {
"concurrently": "^8.2.2"
},
"workspaces": [
"backend",
"frontend",
"cms/cms"
]
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Database Backup Script
# Backs up PostgreSQL databases from Docker container
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="./backups"
mkdir -p "$BACKUP_DIR"
echo "📦 Backing up PostgreSQL databases..."
# Backup backend database
echo "Backing up placebo_backend_db..."
docker exec placebo-postgres-dev pg_dump -U placebo_user -d placebo_backend_db > "$BACKUP_DIR/backend_db_$TIMESTAMP.sql"
# Backup CMS database
echo "Backing up placebo_cms_db..."
docker exec placebo-postgres-dev pg_dump -U placebo_user -d placebo_cms_db > "$BACKUP_DIR/cms_db_$TIMESTAMP.sql"
echo "✅ Backups created in $BACKUP_DIR/"
echo "Backend: backend_db_$TIMESTAMP.sql"
echo "CMS: cms_db_$TIMESTAMP.sql"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Database Reset Script
# Resets PostgreSQL databases to initial state
set -e
echo "⚠️ WARNING: This will reset ALL databases!"
read -p "Are you sure? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 1
fi
echo "🔄 Resetting PostgreSQL databases..."
# Stop backend and CMS to avoid connection issues
echo "Stopping backend and CMS containers..."
docker stop placebo-backend-dev placebo-cms-dev 2>/dev/null || true
# Drop and recreate databases
echo "Resetting databases..."
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS placebo_backend_db;" postgres
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS placebo_cms_db;" postgres
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE placebo_backend_db;" postgres
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE placebo_cms_db;" postgres
# Run initialization script
echo "Running initialization script..."
docker exec placebo-postgres-dev psql -U placebo_user -f /docker-entrypoint-initdb.d/init-postgres-dev.sql postgres
# Restart containers
echo "Restarting containers..."
docker start placebo-backend-dev placebo-cms-dev 2>/dev/null || true
echo "✅ Databases reset successfully!"
echo ""
echo "Note: Backend and CMS will need to restart their services."
echo "For backend: docker restart placebo-backend-dev"
echo "For CMS: docker restart placebo-cms-dev"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Database Restore Script
# Restores PostgreSQL databases from backup
set -e
if [ $# -eq 0 ]; then
echo "Usage: $0 <backup_file> [database_name]"
echo "Example: $0 backups/backend_db_20250101_120000.sql"
echo "Example: $0 backups/backend_db_20250101_120000.sql placebo_backend_db"
exit 1
fi
BACKUP_FILE="$1"
DATABASE="${2:-placebo_backend_db}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ Backup file not found: $BACKUP_FILE"
exit 1
fi
echo "🔄 Restoring database $DATABASE from $BACKUP_FILE..."
# Drop and recreate database
echo "Recreating database..."
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS $DATABASE;" postgres
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE $DATABASE;" postgres
# Restore from backup
echo "Restoring data..."
docker exec -i placebo-postgres-dev psql -U placebo_user -d "$DATABASE" < "$BACKUP_FILE"
echo "✅ Database $DATABASE restored successfully!"
+44
View File
@@ -0,0 +1,44 @@
-- PostgreSQL initialization script for Placebo.mk development
-- Creates separate databases for backend and CMS
-- Create databases if they don't exist
SELECT 'CREATE DATABASE placebo_backend_db'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'placebo_backend_db')\gexec
SELECT 'CREATE DATABASE placebo_cms_db'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'placebo_cms_db')\gexec
-- Create user if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'placebo_user') THEN
CREATE USER placebo_user WITH PASSWORD 'placebo_password';
END IF;
END
$$;
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE placebo_backend_db TO placebo_user;
GRANT ALL PRIVILEGES ON DATABASE placebo_cms_db TO placebo_user;
-- Create extensions (useful for some applications)
\c placebo_backend_db
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
\c placebo_cms_db
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Set search path for convenience
ALTER DATABASE placebo_backend_db SET search_path TO public;
ALTER DATABASE placebo_cms_db SET search_path TO public;
-- Output status
SELECT 'PostgreSQL initialization complete' AS status;
SELECT
datname as database,
pg_size_pretty(pg_database_size(datname)) as size,
(SELECT count(*) FROM pg_tables WHERE schemaname = 'public') as tables_count
FROM pg_database
WHERE datname IN ('placebo_backend_db', 'placebo_cms_db');
+297
View File
@@ -0,0 +1,297 @@
# Database Migration Plan: SQLite → PostgreSQL
## Overview
This document outlines the migration strategy for moving from SQLite (development) to PostgreSQL (production) for both the Backend API and CMS.
## Current State
- **Backend**: Uses SQLite with TypeORM entities
- **CMS**: Uses SQLite with Strapi's internal database
- **Development**: Both use local SQLite files
- **Production**: Need PostgreSQL for scalability and reliability
## Migration Steps
### Phase 1: Database Schema Preparation
#### 1.1 Update Backend Database Configuration
```typescript
// backend/src/config/database.config.ts
export default () => ({
database: {
type: process.env.DATABASE_TYPE || 'sqlite',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db',
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV !== 'production',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
cli: {
migrationsDir: 'src/migrations',
},
},
});
```
#### 1.2 Create TypeORM Migrations
```bash
# Generate migration for existing schema
cd backend
npm run typeorm:generate-migration --name=InitialSchema
# Create migration files for PostgreSQL compatibility
npm run typeorm:create-migration --name=AddPostgresSupport
```
#### 1.3 Update CMS Database Configuration
```javascript
// cms/cms/config/database.js
module.exports = ({ env }) => ({
connection: {
client: env('DATABASE_CLIENT', 'sqlite'),
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'placebo_cms_db'),
user: env('DATABASE_USERNAME', 'placebo_user'),
password: env('DATABASE_PASSWORD', 'placebo_password'),
ssl: env.bool('DATABASE_SSL', false),
},
debug: false,
},
});
```
### Phase 2: Data Migration
#### 2.1 Export SQLite Data
```bash
# Export Backend SQLite data
sqlite3 backend/data.db .dump > backend-data.sql
# Export CMS SQLite data
sqlite3 cms/cms/.tmp/data.db .dump > cms-data.sql
```
#### 2.2 Transform SQL for PostgreSQL
Create transformation scripts:
```python
# scripts/transform-sqlite-to-postgres.py
import re
def transform_sqlite_to_postgres(sqlite_sql):
# Remove SQLite-specific syntax
sqlite_sql = re.sub(r'AUTOINCREMENT', 'SERIAL', sqlite_sql)
sqlite_sql = re.sub(r'INTEGER PRIMARY KEY', 'SERIAL PRIMARY KEY', sqlite_sql)
sqlite_sql = re.sub(r'BLOB', 'BYTEA', sqlite_sql)
sqlite_sql = re.sub(r'DATETIME', 'TIMESTAMP', sqlite_sql)
return sqlite_sql
```
#### 2.3 Import to PostgreSQL
```bash
# Create databases
psql -U placebo_user -h localhost -d placebo_db -c "CREATE DATABASE placebo_backend_db;"
psql -U placebo_user -h localhost -d placebo_db -c "CREATE DATABASE placebo_cms_db;"
# Import transformed data
psql -U placebo_user -h localhost -d placebo_backend_db -f transformed-backend-data.sql
psql -U placebo_user -h localhost -d placebo_cms_db -f transformed-cms-data.sql
```
### Phase 3: Application Updates
#### 3.1 Update Environment Variables
Create `.env.production` files:
```bash
# backend/.env.production
DATABASE_TYPE=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=${POSTGRES_PASSWORD}
DATABASE_NAME=placebo_backend_db
DATABASE_SYNCHRONIZE=false
# cms/cms/.env.production
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=placebo_cms_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=${POSTGRES_PASSWORD}
DATABASE_SSL=false
```
#### 3.2 Update Docker Configuration
Update `docker-compose.yml` to use PostgreSQL for both services.
### Phase 4: Testing
#### 4.1 Local Testing with Docker Compose
```bash
# Start services with PostgreSQL
docker-compose up -d postgres backend cms frontend
# Run data migration
./scripts/migrate-data.sh
# Test endpoints
curl http://localhost:3000/health
curl http://localhost:1337/_health
```
#### 4.2 Data Validation
```sql
-- Verify data counts match
SELECT 'Backend Articles', COUNT(*) FROM articles
UNION ALL
SELECT 'CMS Content Types', COUNT(*) FROM strapi_content_types;
-- Verify relationships
SELECT a.title, COUNT(c.id) as comment_count
FROM articles a
LEFT JOIN comments c ON a.id = c.article_id
GROUP BY a.id;
```
### Phase 5: Production Deployment
#### 5.1 Create Migration Script
```bash
#!/bin/bash
# scripts/deploy-migration.sh
set -e
echo "Starting database migration..."
# Backup existing PostgreSQL data
pg_dump -U placebo_user -h ${PRODUCTION_DB_HOST} -d placebo_backend_db > backup-$(date +%Y%m%d).sql
# Run migrations
npm run typeorm:migration:run
# Verify migration
npm run typeorm:query "SELECT version_num FROM typeorm_migrations ORDER BY version_num DESC LIMIT 1;"
echo "Migration completed successfully!"
```
#### 5.2 Rollback Plan
```bash
#!/bin/bash
# scripts/rollback-migration.sh
set -e
echo "Starting rollback..."
# Restore from backup
psql -U placebo_user -h ${PRODUCTION_DB_HOST} -d placebo_backend_db -f backup-${BACKUP_DATE}.sql
# Revert environment variables
export DATABASE_TYPE=sqlite
export DATABASE_NAME=./data.db
echo "Rollback completed!"
```
## Migration Tools
### 1. SQLite to PostgreSQL Converter
```python
# scripts/sqlite_to_postgres.py
import sqlite3
import psycopg2
import os
def migrate_table(sqlite_conn, pg_conn, table_name):
# Read from SQLite
cursor_sqlite = sqlite_conn.cursor()
cursor_sqlite.execute(f"SELECT * FROM {table_name}")
rows = cursor_sqlite.fetchall()
# Get column names
cursor_sqlite.execute(f"PRAGMA table_info({table_name})")
columns = [col[1] for col in cursor_sqlite.fetchall()]
# Insert into PostgreSQL
cursor_pg = pg_conn.cursor()
placeholders = ', '.join(['%s'] * len(columns))
columns_str = ', '.join(columns)
for row in rows:
cursor_pg.execute(
f"INSERT INTO {table_name} ({columns_str}) VALUES ({placeholders})",
row
)
pg_conn.commit()
```
### 2. Data Validation Script
```python
# scripts/validate_migration.py
def validate_counts(sqlite_conn, pg_conn, table_name):
cursor_sqlite = sqlite_conn.cursor()
cursor_sqlite.execute(f"SELECT COUNT(*) FROM {table_name}")
sqlite_count = cursor_sqlite.fetchone()[0]
cursor_pg = pg_conn.cursor()
cursor_pg.execute(f"SELECT COUNT(*) FROM {table_name}")
pg_count = cursor_pg.fetchone()[0]
return sqlite_count == pg_count
```
## Timeline
### Week 1: Preparation
- Update database configurations
- Create migration scripts
- Set up PostgreSQL locally
### Week 2: Testing
- Test migration locally
- Validate data integrity
- Performance testing
### Week 3: Staging Deployment
- Deploy to staging environment
- User acceptance testing
- Fix any issues
### Week 4: Production Deployment
- Schedule maintenance window
- Execute migration
- Monitor performance
- Rollback if needed
## Risk Mitigation
### High Risks:
1. **Data loss**: Maintain multiple backups
2. **Downtime**: Schedule during low-traffic hours
3. **Performance issues**: Monitor closely after migration
### Mitigation Strategies:
1. Complete backup before migration
2. Gradual rollout with canary deployment
3. Performance monitoring for 48 hours post-migration
## Success Criteria
1. All data migrated without loss
2. Application functionality unchanged
3. Performance equal or better than SQLite
4. Zero downtime during migration
5. All tests passing post-migration
## Post-Migration Tasks
1. Update documentation
2. Remove SQLite dependencies
3. Set up PostgreSQL monitoring
4. Schedule regular backups
5. Update deployment scripts
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# Placebo.mk Setup Script
# This script sets up the development environment
set -e
echo "🚀 Setting up Placebo.mk development environment..."
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first."
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose."
exit 1
fi
# Check Node.js version
if ! command -v node &> /dev/null; then
echo "⚠️ Node.js is not installed. Local mode won't work, but Docker mode will."
fi
# Create necessary directories
echo "📁 Creating directories..."
mkdir -p scripts
mkdir -p backend/uploads
mkdir -p cms/cms/public/uploads
# Set up environment files
echo "⚙️ Setting up environment files..."
# Backend
if [ ! -f backend/.env ]; then
echo "Creating backend/.env from template..."
cp backend/.env.docker.template backend/.env
fi
if [ ! -f backend/.env.local ]; then
echo "Creating backend/.env.local from template..."
cp backend/.env.local.template backend/.env.local
fi
# Frontend
if [ ! -f frontend/.env ]; then
echo "Creating frontend/.env from template..."
cp frontend/.env.docker.template frontend/.env
fi
if [ ! -f frontend/.env.local ]; then
echo "Creating frontend/.env.local from template..."
cp frontend/.env.local.template frontend/.env.local
fi
# Install root dependencies
echo "📦 Installing root dependencies..."
if command -v npm &> /dev/null; then
npm install
else
echo "⚠️ npm not found, skipping root dependencies"
fi
# Build Docker images
echo "🐳 Building Docker images..."
if docker compose version &> /dev/null; then
docker compose -f docker-compose.dev.yml build
else
docker-compose -f docker-compose.dev.yml build
fi
echo ""
echo "✅ Setup complete!"
echo ""
echo "Next steps:"
echo "1. Start in Docker mode: npm run docker:up"
echo "2. Or start in local mode:"
echo " - First terminal: docker-compose -f docker-compose.dev.yml up postgres"
echo " - Second terminal: npm run dev:local"
echo ""
echo "For more details, see README-DEVELOPMENT.md"
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Test script for Placebo.mk Docker setup
set -e
echo "🧪 Testing Placebo.mk Docker setup..."
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null; then
echo "⚠️ docker-compose not found, using docker compose"
DOCKER_COMPOSE="docker compose"
else
DOCKER_COMPOSE="docker-compose"
fi
# Create environment files if they don't exist
echo "📝 Creating environment files..."
# Backend .env
if [ ! -f backend/.env ]; then
cat > backend/.env << EOF
# Backend Environment Variables
NODE_ENV=production
DATABASE_TYPE=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
DATABASE_NAME=placebo_db
DATABASE_SYNCHRONIZE=false
DATABASE_LOGGING=false
JWT_SECRET=your-jwt-secret-key-change-in-production
JWT_EXPIRATION=3600
CORS_ORIGIN=http://localhost:5173,http://localhost:3001
PORT=3000
EOF
echo "✅ Created backend/.env"
fi
# Frontend .env
if [ ! -f frontend/.env ]; then
cat > frontend/.env << EOF
# Frontend Environment Variables
NODE_ENV=production
VITE_API_URL=http://localhost:3000
VITE_CMS_URL=http://localhost:1337
EOF
echo "✅ Created frontend/.env"
fi
# CMS .env
if [ ! -f cms/cms/.env ]; then
cat > cms/cms/.env << EOF
# CMS Environment Variables
NODE_ENV=production
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=placebo_db
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
DATABASE_SSL=false
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_JWT_SECRET=your-admin-jwt-secret-key-change-in-production
APP_KEYS=your-app-keys-change-in-production
API_TOKEN_SALT=your-api-token-salt-change-in-production
TRANSFER_TOKEN_SALT=your-transfer-token-salt-change-in-production
CORS_ORIGIN=http://localhost:5173,http://localhost:3001
PORT=1337
EOF
echo "✅ Created cms/cms/.env"
fi
# Create database initialization script
mkdir -p scripts
cat > scripts/init-db.sql << EOF
-- Create separate databases for backend and CMS
CREATE DATABASE placebo_backend_db;
CREATE DATABASE placebo_cms_db;
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE placebo_backend_db TO placebo_user;
GRANT ALL PRIVILEGES ON DATABASE placebo_cms_db TO placebo_user;
EOF
echo "✅ Created database initialization script"
echo "🐳 Building Docker images..."
$DOCKER_COMPOSE build
echo "🚀 Starting services..."
$DOCKER_COMPOSE up -d
echo "⏳ Waiting for services to be healthy..."
sleep 30
echo "📊 Checking service status..."
$DOCKER_COMPOSE ps
echo "🔍 Testing service connectivity..."
# Test PostgreSQL
echo "Testing PostgreSQL..."
if docker exec placebo-postgres pg_isready -U placebo_user -d placebo_db; then
echo "✅ PostgreSQL is healthy"
else
echo "❌ PostgreSQL health check failed"
exit 1
fi
# Test Backend
echo "Testing Backend API..."
if curl -f http://localhost:3000/health > /dev/null 2>&1; then
echo "✅ Backend API is healthy"
else
echo "❌ Backend API health check failed"
exit 1
fi
# Test CMS
echo "Testing CMS..."
if curl -f http://localhost:1337/_health > /dev/null 2>&1; then
echo "✅ CMS is healthy"
else
echo "❌ CMS health check failed"
exit 1
fi
# Test Frontend
echo "Testing Frontend..."
if curl -f http://localhost:3001 > /dev/null 2>&1; then
echo "✅ Frontend is serving"
else
echo "❌ Frontend health check failed"
exit 1
fi
echo ""
echo "🎉 All tests passed! Docker setup is working correctly."
echo ""
echo "Services are running at:"
echo " Frontend: http://localhost:3001"
echo " Backend API: http://localhost:3000"
echo " CMS Admin: http://localhost:1337/admin"
echo ""
echo "To stop services: docker-compose down"
echo "To view logs: docker-compose logs -f"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Test environment configuration
echo "🧪 Testing environment configuration..."
echo ""
echo "=== Backend Environment ==="
echo "Current .env DATABASE_HOST:"
grep DATABASE_HOST backend/.env || echo "Not found"
echo ""
echo "Backend .env.local DATABASE_HOST:"
grep DATABASE_HOST backend/.env.local || echo "Not found"
echo ""
echo "=== Frontend Environment ==="
echo "Current .env VITE_API_URL:"
grep VITE_API_URL frontend/.env || echo "Not found"
echo ""
echo "Frontend .env.local VITE_API_URL:"
grep VITE_API_URL frontend/.env.local || echo "Not found"
echo ""
echo "=== Docker Compose ==="
echo "Checking Docker Compose configuration..."
if grep -q "env_file:" docker-compose.dev.yml; then
echo "✓ env_file directive is present"
else
echo "✗ env_file directive not found"
fi
echo ""
echo "✅ Environment test complete!"
echo ""
echo "To switch to local mode:"
echo " backend: npm run dev:local"
echo " frontend: npm run dev:local"
echo ""
echo "To reset to Docker mode:"
echo " npm run reset:env"
+9
View File
@@ -0,0 +1,9 @@
1. role based auth [admin, contributor, user]
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality
lets implement a role based auth
admin can create, edit and delete articles and liveblogs
admin can create contributors
contributors can create, edit and delete articles and liveblogs
user can react(like, dislike) to articles and write comments
+1
View File
@@ -0,0 +1 @@
Live-blog webhooks work correctly: POST /api/v1/webhooks/strapi/live-blog