15 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
dimitar 4ccb65ba88 pin to homepage and liveblog ticker 2026-01-29 06:06:54 +01:00
dimitar 5a457fa99f checkpoint
need polishing
2026-01-29 05:13:52 +01:00
dimitar 0351726eeb live blog implemented 2026-01-29 04:01:55 +01:00
dimitar e8893c1aae ticket implemented 2026-01-29 01:26:48 +01:00
86 changed files with 46536 additions and 291 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.
+159
View File
@@ -13,10 +13,12 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@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",
@@ -2410,6 +2412,19 @@
}
}
},
"node_modules/@nestjs/event-emitter": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz",
"integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.10",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.10.tgz",
@@ -5741,6 +5756,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -9115,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",
@@ -9233,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",
@@ -10061,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",
+6 -1
View File
@@ -19,17 +19,22 @@
"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",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@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",
+16 -4
View File
@@ -5,7 +5,14 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module';
import { Article, Author, Category } from './modules/entities';
import { LiveBlogModule } from './modules/live-blog.module';
import {
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
} from './modules/entities';
@Module({
imports: [
@@ -13,14 +20,19 @@ import { Article, Author, Category } from './modules/entities';
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DATABASE_PATH ?? './database.sqlite',
entities: [Article, Author, Category],
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',
}),
ArticlesModule,
StrapiModule,
LiveBlogModule,
],
controllers: [AppController],
providers: [AppService],
+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);
}
}
+237 -4
View File
@@ -5,8 +5,16 @@ import {
IsArray,
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import { ArticleStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
@@ -47,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 {
@@ -90,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 {
@@ -106,8 +154,194 @@ export class FindArticlesDto {
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto {
@IsString()
title: string;
@IsString()
slug: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@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 {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@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 {
@IsString()
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class UpdateLiveBlogUpdateDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
@@ -118,6 +352,5 @@ export class FindArticlesDto {
page?: number;
@IsOptional()
@IsNumber()
limit?: number;
}
+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}`);
}
}
+171 -2
View File
@@ -7,6 +7,7 @@ import {
ManyToOne,
JoinColumn,
ValueTransformer,
OneToMany,
} from 'typeorm';
class ArrayTransformer implements ValueTransformer {
@@ -15,7 +16,7 @@ class ArrayTransformer implements ValueTransformer {
}
from(value: string): string[] {
try {
return value ? JSON.parse(value) : [];
return value ? (JSON.parse(value) as string[]) : [];
} catch {
return [];
}
@@ -28,6 +29,33 @@ export enum ArticleStatus {
ARCHIVED = 'archived',
}
export enum LiveBlogStatus {
DRAFT = 'draft',
LIVE = 'live',
ENDED = 'ended',
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')
@@ -106,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: '[]',
@@ -122,7 +174,7 @@ export class Article {
@Column({ default: 0 })
views: number;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@@ -145,3 +197,120 @@ export class Article {
@JoinColumn({ name: 'categoryId' })
category: Category;
}
@Entity('live_blogs')
export class LiveBlog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ unique: true })
slug: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'text',
default: 'draft',
})
status: LiveBlogStatus;
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
authorId: string;
@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;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'authorId' })
author: Author;
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' })
category: Category;
@OneToMany(() => LiveBlogUpdate, (update) => update.liveBlog, {
cascade: true,
})
updates: LiveBlogUpdate[];
}
@Entity('live_blog_updates')
export class LiveBlogUpdate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true })
authorId: string;
@Column({ type: 'timestamp', nullable: true })
scheduledAt: Date;
@Column({ nullable: true })
strapiId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'authorId' })
author: Author;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
}
+151
View File
@@ -0,0 +1,151 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
ValidationPipe,
Res,
Logger,
Headers,
} from '@nestjs/common';
import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service';
import {
CreateLiveBlogDto,
UpdateLiveBlogDto,
FindLiveBlogsDto,
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { LiveBlogStatus } from './entities';
@Controller('live-blogs')
export class LiveBlogController {
private readonly logger = new Logger(LiveBlogController.name);
constructor(private readonly liveBlogService: LiveBlogService) {}
// Live Blog CRUD operations
@Get('featured')
getFeatured() {
this.logger.log('GET /featured called');
return this.liveBlogService.findPinned();
}
@Get('active')
getActive() {
return this.liveBlogService.findActive();
}
@Get('recent')
getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
}
@Post()
create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
) {
return this.liveBlogService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
) {
return this.liveBlogService.findAll(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id);
}
@Get('slug/:slug')
findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
) {
return this.liveBlogService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.liveBlogService.remove(id);
}
// Live Blog Updates CRUD operations
@Post(':id/updates')
createUpdate(
@Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
) {
return this.liveBlogService.createUpdate(dto, liveBlogId);
}
@Get(':id/updates')
findUpdates(
@Param('id') liveBlogId: string,
@Query('page') page = 1,
@Query('limit') limit = 50,
) {
return this.liveBlogService.findUpdates(
liveBlogId,
parseInt(page.toString()),
parseInt(limit.toString()),
);
}
@Put(':id/updates/:updateId')
updateUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
}
@Delete(':id/updates/:updateId')
removeUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
) {
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(
@Param('id') liveBlogId: string,
@Res() response: ExpressResponse,
@Headers('last-event-id') lastEventId?: string,
) {
this.logger.log(`SSE connection request for live blog ${liveBlogId}`);
if (lastEventId) {
this.logger.log(`Client resuming with last event ID: ${lastEventId}`);
}
this.liveBlogService.createStream(liveBlogId, response);
}
}
+19
View File
@@ -0,0 +1,19 @@
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],
exports: [LiveBlogService],
})
export class LiveBlogModule {}
+632
View File
@@ -0,0 +1,632 @@
import {
Injectable,
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,
FindLiveBlogsDto,
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
interface SseClient {
id: string;
response: Response;
blogId: string;
}
interface LiveBlogUpdateEvent {
blogId: string;
update: LiveBlogUpdate;
}
interface LiveBlogStatusChangeEvent {
blogId: string;
status: LiveBlogStatus;
}
interface LiveBlogPinUpdateEvent {
blogId: string;
updateId: string;
isPinned: boolean;
}
@Injectable()
export class LiveBlogService implements OnModuleInit {
private readonly logger = new Logger(LiveBlogService.name);
private readonly sseClients = new Map<string, SseClient>();
constructor(
@InjectRepository(LiveBlog)
private readonly liveBlogRepository: Repository<LiveBlog>,
@InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {}
onModuleInit() {
this.eventEmitter.on('live-blog.update', (data: LiveBlogUpdateEvent) => {
this.broadcastToClients(data.blogId, {
type: 'update',
data: data.update,
});
});
this.eventEmitter.on(
'live-blog.status-change',
(data: LiveBlogStatusChangeEvent) => {
this.broadcastToClients(data.blogId, {
type: 'status-change',
data: { status: data.status },
});
},
);
this.eventEmitter.on(
'live-blog.pin-update',
(data: LiveBlogPinUpdateEvent) => {
this.broadcastToClients(data.blogId, {
type: 'pin-update',
data: { updateId: data.updateId, isPinned: data.isPinned },
});
},
);
}
// Live Blog CRUD operations
async create(dto: CreateLiveBlogDto): Promise<LiveBlog> {
const liveBlog = this.liveBlogRepository.create({
...dto,
status: dto.status || LiveBlogStatus.DRAFT,
});
return await this.liveBlogRepository.save(liveBlog);
}
async findAll(
dto: FindLiveBlogsDto,
): Promise<{ data: LiveBlog[]; total: number }> {
const {
category,
author,
status,
isPinned,
search,
page = 1,
limit = 10,
} = dto;
const queryBuilder = this.liveBlogRepository
.createQueryBuilder('liveBlog')
.leftJoinAndSelect('liveBlog.author', 'author')
.leftJoinAndSelect('liveBlog.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('liveBlog.status IN (:...statuses)', { statuses });
} else {
queryBuilder.where('liveBlog.status = :status', { status });
}
}
// 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 });
}
if (author) {
queryBuilder.andWhere('author.slug = :author', { author });
}
if (search) {
queryBuilder.andWhere(
'(liveBlog.title ILIKE :search OR liveBlog.description ILIKE :search)',
{ search: `%${search}%` },
);
}
if (isPinned !== undefined) {
queryBuilder.andWhere('liveBlog.isPinned = :isPinned', { isPinned });
}
const [data, total] = await queryBuilder
.orderBy('liveBlog.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
async findOne(id: string): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { id },
relations: ['author', 'category', 'updates'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with ID ${id} not found`);
}
// Increment view count
await this.liveBlogRepository.increment({ id }, 'viewCount', 1);
return liveBlog;
}
async findOneWithoutIncrement(id: string): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { id },
relations: ['author', 'category', 'updates'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with ID ${id} not found`);
}
return liveBlog;
}
async findBySlug(slug: string): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { slug },
relations: ['author', 'category', 'updates'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with slug ${slug} not found`);
}
// Increment view count
await this.liveBlogRepository.increment({ slug }, 'viewCount', 1);
return liveBlog;
}
async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { id },
relations: ['author', 'category'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with ID ${id} not found`);
}
// Track if status changed for event emission
const oldStatus = liveBlog.status;
// Update fields
if (dto.title !== undefined) {
liveBlog.title = dto.title;
}
if (dto.slug !== undefined) {
liveBlog.slug = dto.slug;
}
if (dto.description !== undefined) {
liveBlog.description = dto.description;
}
if (dto.status !== undefined) {
liveBlog.status = dto.status;
}
if (dto.strapiId !== undefined) {
liveBlog.strapiId = dto.strapiId;
}
if (dto.authorId !== undefined) {
liveBlog.authorId = dto.authorId;
}
if (dto.categoryId !== undefined) {
liveBlog.categoryId = dto.categoryId;
}
if (dto.isPinned !== undefined) {
liveBlog.isPinned = dto.isPinned;
}
// Save the updated entity
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
// Emit status change event if status changed
if (dto.status !== undefined && dto.status !== oldStatus) {
this.eventEmitter.emit('live-blog.status-change', {
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;
}
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,
liveBlogId: string,
): Promise<LiveBlogUpdate> {
const liveBlogEntity = await this.findOne(liveBlogId);
const update = this.liveBlogUpdateRepository.create({
...dto,
liveBlog: liveBlogEntity,
});
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
// Emit update event
this.eventEmitter.emit('live-blog.update', {
blogId: liveBlogId,
update: savedUpdate,
});
return savedUpdate;
}
async findUpdates(
liveBlogId: string,
page = 1,
limit = 50,
): Promise<{ data: LiveBlogUpdate[]; total: number }> {
const [data, total] = await this.liveBlogUpdateRepository.findAndCount({
where: { liveBlog: { id: liveBlogId } },
relations: ['author'],
order: {
isPinned: 'DESC',
createdAt: 'ASC',
},
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async updateUpdate(
liveBlogId: string,
updateId: string,
dto: UpdateLiveBlogUpdateDto,
): Promise<LiveBlogUpdate> {
const update = await this.liveBlogUpdateRepository.findOne({
where: { id: updateId, liveBlog: { id: liveBlogId } },
relations: ['liveBlog'],
});
if (!update) {
throw new NotFoundException(`Update with ID ${updateId} not found`);
}
Object.assign(update, dto);
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
// Emit pin change event
if (dto.isPinned !== undefined) {
this.eventEmitter.emit('live-blog.pin-update', {
blogId: liveBlogId,
updateId,
isPinned: dto.isPinned,
});
}
return savedUpdate;
}
async removeUpdate(liveBlogId: string, updateId: string): Promise<void> {
const update = await this.liveBlogUpdateRepository.findOne({
where: { id: updateId, liveBlog: { id: liveBlogId } },
});
if (!update) {
throw new NotFoundException(`Update with ID ${updateId} not found`);
}
await this.liveBlogUpdateRepository.remove(update);
}
// SSE operations
createStream(liveBlogId: string, response: Response): void {
// Verify live blog exists and is live
this.findOne(liveBlogId).catch(() => {
response.end();
return;
});
const clientId = `${Date.now()}-${Math.random()}`;
// Set SSE headers
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
});
// Send initial connection message
response.write(
`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`,
);
// Store client connection
this.sseClients.set(clientId, {
id: clientId,
response,
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}`,
);
});
this.logger.log(`Client ${clientId} connected to live blog ${liveBlogId}`);
}
private broadcastToClients(liveBlogId: string, message: any): void {
const clients = Array.from(this.sseClients.values()).filter(
(client) => client.blogId === liveBlogId,
);
clients.forEach((client) => {
try {
client.response.write(`data: ${JSON.stringify(message)}\n\n`);
} catch (error) {
this.logger.error(
`Failed to send message to client ${client.id}:`,
error,
);
this.sseClients.delete(client.id);
}
});
this.logger.debug(
`Broadcasted message to ${clients.length} clients for live blog ${liveBlogId}`,
);
}
// Strapi sync operations
async syncFromStrapi(
strapiId: string,
data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> {
// 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) {
// 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,
});
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(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
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
const since = new Date();
since.setHours(since.getHours() - hours);
return await this.liveBlogRepository
.createQueryBuilder('liveBlog')
.leftJoinAndSelect('liveBlog.author', 'author')
.leftJoinAndSelect('liveBlog.updates', 'updates')
.where('liveBlog.status = :status', { status: LiveBlogStatus.LIVE })
.andWhere('updates.createdAt > :since', { since })
.orderBy('updates.createdAt', 'DESC')
.getMany();
}
async findPinned(): Promise<LiveBlog[]> {
return this.liveBlogRepository.find({
where: {
isPinned: true,
status: In([LiveBlogStatus.LIVE, LiveBlogStatus.ENDED]), // Show both live and ended pinned blogs
},
relations: ['author', 'category', 'updates'],
order: { updatedAt: 'DESC' },
take: 5,
});
}
async findActive(): Promise<LiveBlog[]> {
return this.liveBlogRepository.find({
where: { status: LiveBlogStatus.LIVE },
relations: ['author', 'category'],
order: { createdAt: 'DESC' },
take: 20,
});
}
}
+109 -4
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,23 +16,123 @@ 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' };
}
@Post('sync/all')
async syncAllArticles() {
await this.strapiService.syncArticles();
return { message: 'Sync completed' };
return { message: 'Articles sync completed' };
}
@Post('sync/live-blogs')
async syncAllLiveBlogs() {
await this.strapiService.syncLiveBlogs();
return { message: 'Live blogs sync completed' };
}
@Post('sync/everything')
async syncEverything() {
await Promise.all([
this.strapiService.syncArticles(),
this.strapiService.syncLiveBlogs(),
]);
return { message: 'Full sync completed' };
}
}
+7 -2
View File
@@ -1,11 +1,16 @@
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';
import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module';
@Module({
imports: [HttpModule, ArticlesModule],
imports: [
HttpModule,
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],
+530 -17
View File
@@ -1,10 +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 { CreateArticleDto } from './articles.dto';
import { ArticleStatus } from './entities';
import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
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;
@@ -16,6 +38,32 @@ 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 {
id: number;
documentId: string;
title: string;
description: string;
slug: string;
status: 'draft' | 'live' | 'ended' | 'archived';
publishedAt: string | null;
createdAt: string;
updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
}
interface StrapiResponse<T> {
@@ -39,7 +87,10 @@ 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 =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -57,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(),
},
@@ -74,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,
@@ -83,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(
@@ -101,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(),
},
@@ -115,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,
@@ -139,18 +312,358 @@ export class StrapiService {
throw error;
}
}
}
async syncLiveBlogs(): Promise<void> {
try {
this.logger.log('Starting live blogs sync from Strapi...');
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs?populate=*`,
{
headers: this.getHeaders(),
},
),
);
const strapiLiveBlogs = response.data.data;
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(
strapiLiveBlog.documentId,
liveBlogData,
);
syncedCount++;
}
this.logger.log(
`Successfully synced ${syncedCount} live blogs from Strapi`,
);
} catch (error) {
this.logger.error('Failed to sync live blogs from Strapi', error);
throw error;
}
}
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}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
),
);
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,
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} 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,
);
throw error;
}
}
}
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
switch (status) {
case 'live':
return LiveBlogStatus.LIVE;
case 'draft':
return LiveBlogStatus.DRAFT;
case 'ended':
return LiveBlogStatus.ENDED;
case 'archived':
return LiveBlogStatus.ARCHIVED;
default:
return LiveBlogStatus.DRAFT;
}
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete',
data: { documentId: string },
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(`Received webhook event: ${event}`);
this.logger.log(
`Received webhook event: ${event} for model: ${data.model}`,
);
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;
}
await this.syncSingleArticle(data.documentId);
// Route to appropriate sync method based on model
if (data.model === 'article') {
await this.syncSingleArticle(data.documentId, event);
} else if (data.model === 'live-blog') {
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;
}
}
}
+2721 -22
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -12,15 +12,25 @@
"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",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@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",
+4327
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticleTicker() {
const { data } = useQuery({
queryKey: ['ticker-articles'],
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
})
const articles = data?.data.slice(0, 10) || []
if (articles.length === 0) return null
return (
<div className="overflow-hidden bg-muted/50 border-y">
<div className="container mx-auto max-w-6xl px-4">
<div className="py-2 flex items-center gap-4">
<span className="text-sm font-semibold text-primary whitespace-nowrap">
Latest:
</span>
<div className="overflow-hidden flex-1 relative">
<div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => (
<Link
key={`${article.id}-${index}`}
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'}
</Link>
))}
{/* Duplicate for seamless scrolling */}
{articles.map((article, index) => (
<Link
key={`dup-${article.id}-${index}`}
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'}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { useCreateLiveBlog } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNavigate } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
interface CreateLiveBlogProps {
onSuccess?: () => void;
className?: string;
}
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
slug: '',
description: '',
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const createMutation = useCreateLiveBlog();
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-')
.trim();
};
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const title = e.target.value;
setFormData({
...formData,
title,
slug: formData.slug || generateSlug(title),
});
if (errors.title) setErrors({ ...errors, title: '' });
};
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, slug: e.target.value });
if (errors.slug) setErrors({ ...errors, slug: '' });
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.slug.trim()) {
newErrors.slug = 'Slug is required';
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const result = await createMutation.mutateAsync(formData);
// Reset form
setFormData({
title: '',
slug: '',
description: '',
status: 'draft',
});
setErrors({});
// Navigate to the new live blog
navigate({ to: `/admin/live-blogs/${result.slug}` });
if (onSuccess) onSuccess();
} catch (error) {
console.error('Failed to create live blog:', error);
setErrors({ submit: 'Failed to create live blog. Please try again.' });
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Create New Live Blog
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={handleTitleChange}
placeholder="Enter live blog title"
disabled={createMutation.isPending}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={formData.slug}
onChange={handleSlugChange}
placeholder="URL-friendly identifier"
disabled={createMutation.isPending}
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug}</p>
)}
<p className="text-xs text-muted-foreground">
This will be used in the URL: /live-blogs/your-slug-here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description for this live blog"
rows={3}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Initial Status</Label>
<Select
value={formData.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setFormData({ ...formData, status: value })
}
disabled={createMutation.isPending}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
You can change this later in the settings
</p>
</div>
{errors.submit && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{errors.submit}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="submit"
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,531 @@
import { useState } from 'react';
import {
useLiveBlog,
useDeleteLiveBlogUpdate,
useUpdateLiveBlog,
useDeleteLiveBlog
} from '@/queries/live-blogs';
import { UpdatePublisher } from './UpdatePublisher';
import { LiveBlogUpdate } from '@/components/features/live-blog/LiveBlogUpdate';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { LiveBlogUpdate as ApiLiveBlogUpdate, UpdateLiveBlogDto } from '@/lib/api';
import {
ArrowLeft,
Edit,
Play,
Square,
Archive,
Eye,
MessageSquare,
Pin,
PinOff
} from 'lucide-react';
interface LiveBlogManagerProps {
slug: string;
onBack?: () => void;
className?: string;
}
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
const [activeTab, setActiveTab] = useState('updates');
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
const updateLiveBlogMutation = useUpdateLiveBlog();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const handleDeleteUpdate = async (update: ApiLiveBlogUpdate) => {
if (!liveBlog) return;
if (confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
try {
await deleteUpdateMutation.mutateAsync({
liveBlogId: liveBlog.id,
updateId: update.id,
});
} catch (error) {
console.error('Failed to delete update:', error);
}
}
};
const handleEditUpdate = (update: ApiLiveBlogUpdate) => {
// The UpdatePublisher component handles editing internally
console.log('Editing update:', update);
};
const handleStartEdit = () => {
if (!liveBlog) return;
setIsEditing(true);
setEditForm({
title: liveBlog.title,
slug: liveBlog.slug,
description: liveBlog.description || '',
status: liveBlog.status,
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm({});
};
const handleSaveEdit = async () => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: editForm,
});
setIsEditing(false);
setEditForm({});
} catch (error) {
console.error('Failed to update live blog:', error);
}
};
const handleStatusChange = async (newStatus: 'draft' | 'live' | 'ended' | 'archived') => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: { status: newStatus },
});
} catch (error) {
console.error('Failed to update status:', error);
alert(`Failed to update status: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTogglePin = async () => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: { isPinned: !liveBlog.isPinned },
});
} catch (error) {
console.error('Failed to toggle pin:', error);
alert(`Failed to toggle pin: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleDeleteLiveBlog = async () => {
if (!liveBlog) return;
if (confirm('Are you sure you want to delete this live blog? This action cannot be undone and will delete all updates.')) {
try {
await deleteLiveBlogMutation.mutateAsync(liveBlog.id);
if (onBack) onBack();
} catch (error) {
console.error('Failed to delete live blog:', error);
}
}
};
if (isLoading) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="h-32 bg-muted rounded"></div>
</div>
</CardContent>
</Card>
);
}
if (error || !liveBlog) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="text-center text-destructive">
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
<p className="text-sm mb-4">
{error instanceof Error ? error.message : 'Unknown error occurred'}
</p>
{onBack && (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
)}
</div>
</CardContent>
</Card>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'live': return 'bg-green-100 text-green-800 border-green-200';
case 'draft': return 'bg-gray-100 text-gray-800 border-gray-200';
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
case 'archived': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'live': return <Play className="w-3 h-3" />;
case 'draft': return <Edit className="w-3 h-3" />;
case 'ended': return <Square className="w-3 h-3" />;
case 'archived': return <Archive className="w-3 h-3" />;
default: return null;
}
};
return (
<div className={className}>
{/* Header */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<CardTitle className="text-2xl">{liveBlog.title}</CardTitle>
</div>
{liveBlog.description && (
<p className="text-muted-foreground mb-4">{liveBlog.description}</p>
)}
<div className="flex items-center gap-4 text-sm">
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
<div className="flex items-center gap-1">
{getStatusIcon(liveBlog.status)}
{liveBlog.status}
</div>
</Badge>
<div className="flex items-center gap-1 text-muted-foreground">
<Eye className="w-4 h-4" />
{liveBlog.viewCount} views
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<MessageSquare className="w-4 h-4" />
{liveBlog.updates?.length || 0} updates
</div>
{liveBlog.author && (
<div className="flex items-center gap-2">
{liveBlog.author.avatar && (
<img
src={liveBlog.author.avatar}
alt={liveBlog.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-muted-foreground">{liveBlog.author.name}</span>
</div>
)}
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Main content */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="updates">Updates</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="updates" className="space-y-6">
<UpdatePublisher
liveBlogId={liveBlog.id}
authorId={liveBlog.authorId || undefined}
/>
{/* Recent updates preview */}
{liveBlog.updates && liveBlog.updates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">All Updates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{liveBlog.updates.map((update) => (
<LiveBlogUpdate
key={update.id}
update={update}
showAdminControls={true}
onEdit={handleEditUpdate}
onDelete={handleDeleteUpdate}
onPinToggle={(update) => {
// Handle pin toggle
console.log('Toggle pin for update:', update);
}}
/>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Live Blog Settings</CardTitle>
{!isEditing ? (
<Button variant="outline" size="sm" onClick={handleStartEdit}>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveEdit}>
Save Changes
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{isEditing ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={editForm.title || ''}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={editForm.slug || ''}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
placeholder="URL-friendly identifier"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={editForm.description || ''}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={editForm.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setEditForm({ ...editForm, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="live">Live</SelectItem>
<SelectItem value="ended">Ended</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Status Management</h4>
<p className="text-sm text-muted-foreground mb-4">
Control the live blog status and visibility
</p>
<div className="flex flex-wrap gap-2">
{liveBlog.status !== 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('live')}
disabled={updateLiveBlogMutation.isPending}
>
<Play className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Go Live'}
</Button>
)}
{liveBlog.status === 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('ended')}
disabled={updateLiveBlogMutation.isPending}
>
<Square className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'End Live Session'}
</Button>
)}
{liveBlog.status !== 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('archived')}
disabled={updateLiveBlogMutation.isPending}
>
<Archive className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Archive'}
</Button>
)}
{liveBlog.status === 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('draft')}
disabled={updateLiveBlogMutation.isPending}
>
<Edit className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'}
</Button>
)}
<Button
variant={liveBlog.isPinned ? "default" : "outline"}
size="sm"
onClick={handleTogglePin}
disabled={updateLiveBlogMutation.isPending}
>
{liveBlog.isPinned ? (
<>
<PinOff className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unpin'}
</>
) : (
<>
<Pin className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Pin to Homepage'}
</>
)}
</Button>
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Live Blog Information</h4>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Title:</span>
<span className="font-medium">{liveBlog.title}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Slug:</span>
<span className="font-medium">{liveBlog.slug}</span>
</div>
{liveBlog.description && (
<div className="flex justify-between">
<span className="text-muted-foreground">Description:</span>
<span className="font-medium text-right max-w-xs">{liveBlog.description}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Status:</span>
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
<div className="flex items-center gap-1">
{getStatusIcon(liveBlog.status)}
{liveBlog.status}
</div>
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pinned:</span>
<Badge variant={liveBlog.isPinned ? "default" : "outline"}>
<div className="flex items-center gap-1">
{liveBlog.isPinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{liveBlog.isPinned ? 'Yes' : 'No'}
</div>
</Badge>
</div>
</div>
</div>
</>
)}
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Analytics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Total Views:</span>
<div className="font-medium">{liveBlog.viewCount}</div>
</div>
<div>
<span className="text-muted-foreground">Total Updates:</span>
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
</div>
<div>
<span className="text-muted-foreground">Created:</span>
<div className="font-medium">
{new Date(liveBlog.createdAt).toLocaleDateString()}
</div>
</div>
<div>
<span className="text-muted-foreground">Last Updated:</span>
<div className="font-medium">
{new Date(liveBlog.updatedAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
<div className="p-4 border border-destructive/20 rounded-lg">
<h4 className="font-medium mb-2 text-destructive">Danger Zone</h4>
<p className="text-sm text-muted-foreground mb-4">
Once you delete a live blog, there is no going back. Please be certain.
</p>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteLiveBlog}
disabled={deleteLiveBlogMutation.isPending}
>
{deleteLiveBlogMutation.isPending ? 'Deleting...' : 'Delete Live Blog'}
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,263 @@
import React, { useState, useRef } from 'react';
import { useCreateLiveBlogUpdate, useUpdateLiveBlogUpdate, useLiveBlogUpdates } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import type { LiveBlogUpdate, CreateLiveBlogUpdateDto } from '@/lib/api';
import { cn } from '@/lib/utils';
import { Send, Pin, Calendar, Edit } from 'lucide-react';
interface UpdatePublisherProps {
liveBlogId: string;
authorId?: string;
className?: string;
}
export function UpdatePublisher({ liveBlogId, authorId, className }: UpdatePublisherProps) {
const [content, setContent] = useState('');
const [isPinned, setIsPinned] = useState(false);
const [scheduledAt, setScheduledAt] = useState('');
const [isScheduleMode, setIsScheduleMode] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const createMutation = useCreateLiveBlogUpdate();
const updateMutation = useUpdateLiveBlogUpdate();
const [editingUpdate, setEditingUpdate] = useState<LiveBlogUpdate | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
const updateData: CreateLiveBlogUpdateDto = {
content: content.trim(),
isPinned,
authorId,
...(isScheduleMode && scheduledAt ? { scheduledAt } : {}),
};
try {
if (editingUpdate) {
// Update existing update
await updateMutation.mutateAsync({
liveBlogId,
updateId: editingUpdate.id,
dto: {
content: content.trim(),
isPinned,
},
});
setEditingUpdate(null);
} else {
// Create new update
await createMutation.mutateAsync({
liveBlogId,
dto: updateData,
});
}
// Reset form
setContent('');
setIsPinned(false);
setScheduledAt('');
setIsScheduleMode(false);
textareaRef.current?.focus();
} catch (error) {
console.error('Failed to save update:', error);
}
};
const handleEdit = (update: LiveBlogUpdate) => {
setContent(update.content);
setIsPinned(update.isPinned);
setEditingUpdate(update);
setIsScheduleMode(false);
textareaRef.current?.focus();
};
const handleCancelEdit = () => {
setEditingUpdate(null);
setContent('');
setIsPinned(false);
setIsScheduleMode(false);
};
const isLoading = createMutation.isPending || updateMutation.isPending;
// Get minimum date for scheduling (now + 5 minutes)
const minScheduleDate = new Date().toISOString().slice(0, 16);
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{editingUpdate ? 'Edit Update' : 'Publish Update'}
{editingUpdate && (
<span className="text-xs bg-muted px-2 py-1 rounded-full">
ID: {editingUpdate.id.slice(0, 8)}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Content textarea */}
<div className="space-y-2">
<Label htmlFor="content">
{editingUpdate ? 'Edit content' : 'Update content'}
</Label>
<Textarea
id="content"
ref={textareaRef}
placeholder="What's the latest update?"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="resize-none"
disabled={isLoading}
/>
</div>
{/* Options */}
<div className="flex flex-wrap gap-4">
{/* Pin toggle */}
<div className="flex items-center space-x-2">
<Switch
id="pin"
checked={isPinned}
onCheckedChange={setIsPinned}
disabled={isLoading}
/>
<Label htmlFor="pin" className="flex items-center gap-2">
<Pin className="w-4 h-4" />
Pin to top
</Label>
</div>
{/* Schedule toggle */}
<div className="flex items-center space-x-2">
<Switch
id="schedule"
checked={isScheduleMode}
onCheckedChange={setIsScheduleMode}
disabled={isLoading}
/>
<Label htmlFor="schedule" className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
Schedule
</Label>
</div>
</div>
{/* Schedule date/time picker */}
{isScheduleMode && (
<div className="space-y-2">
<Label htmlFor="scheduleAt">Schedule for (minimum 5 minutes from now)</Label>
<input
id="scheduleAt"
type="datetime-local"
min={minScheduleDate}
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full p-2 border rounded-md"
disabled={isLoading}
/>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
<Button
type="submit"
disabled={!content.trim() || isLoading}
className="flex-1"
>
{isLoading ? (
'Saving...'
) : editingUpdate ? (
'Update'
) : (
<>
<Send className="w-4 h-4 mr-2" />
{isScheduleMode ? 'Schedule' : 'Publish'}
</>
)}
</Button>
{editingUpdate && (
<Button
type="button"
variant="outline"
onClick={handleCancelEdit}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
{/* Recent updates for editing */}
{editingUpdate === null && (
<div className="mt-6 pt-6 border-t">
<h4 className="text-sm font-medium mb-3">Recent Updates (Click to edit)</h4>
<RecentUpdates
liveBlogId={liveBlogId}
onEdit={handleEdit}
isLoading={isLoading}
/>
</div>
)}
</CardContent>
</Card>
);
}
interface RecentUpdatesProps {
liveBlogId: string;
onEdit: (update: LiveBlogUpdate) => void;
isLoading: boolean;
}
function RecentUpdates({ liveBlogId, onEdit, isLoading }: RecentUpdatesProps) {
const { data: updatesData } = useLiveBlogUpdates(liveBlogId, 1, 5);
const updates = updatesData?.data || [];
if (updates.length === 0) {
return (
<p className="text-sm text-muted-foreground">No updates yet. Start by publishing your first update!</p>
);
}
return (
<div className="space-y-2">
{updates.map((update) => (
<div
key={update.id}
className={cn(
'p-3 rounded border cursor-pointer hover:bg-muted/50 transition-colors',
isLoading && 'opacity-50 pointer-events-none'
)}
onClick={() => onEdit(update)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{update.isPinned && <Pin className="w-3 h-3 text-primary" />}
<span className="text-xs text-muted-foreground">
{new Date(update.createdAt).toLocaleString()}
</span>
</div>
<Edit className="w-3 h-3 text-muted-foreground" />
</div>
<p className="text-sm line-clamp-2">{update.content}</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,238 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
interface LiveBlogTickerProps {
className?: string;
maxItems?: number;
autoScroll?: boolean;
scrollSpeed?: number; // pixels per second
}
export function LiveBlogTicker({
className = '',
maxItems = 10,
autoScroll = true,
scrollSpeed = 30
}: LiveBlogTickerProps) {
const { data: activeBlogs, isLoading } = useActiveLiveBlogs();
const [isPaused, setIsPaused] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0);
const tickerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => {
if (contentRef.current) {
const width = contentRef.current.scrollWidth;
setTotalWidth(width);
}
}, [activeBlogs]);
// Auto-scroll animation
useEffect(() => {
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
return;
}
let lastTime: number;
const animate = (time: number) => {
if (!lastTime) lastTime = time;
const delta = time - lastTime;
lastTime = time;
setScrollPosition(prev => {
let newPos = prev + (scrollSpeed * delta) / 1000;
// Reset when scrolled past content
if (newPos > totalWidth) {
newPos = -(tickerRef.current?.offsetWidth || 0);
}
return newPos;
});
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [autoScroll, isPaused, activeBlogs, scrollSpeed, totalWidth]);
if (isLoading) {
return (
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
</div>
<div className="animate-pulse h-8 w-20 bg-muted rounded"></div>
</div>
</div>
);
}
if (!activeBlogs || activeBlogs.length === 0) {
return null; // Don't show ticker if no active blogs
}
const displayBlogs = activeBlogs.slice(0, maxItems);
const handlePauseToggle = () => {
setIsPaused(!isPaused);
};
const handleBlogClick = () => {
// Reset scroll position when user interacts
setScrollPosition(0);
};
return (
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}>
{/* Ticker header */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
<div className="flex items-center gap-2">
<Radio className="w-4 h-4 text-primary" />
<span className="font-medium text-sm">Live Now</span>
<Badge variant="secondary" className="text-xs">
{activeBlogs.length} active
</Badge>
</div>
<div className="flex items-center gap-2">
{autoScroll && (
<Button
variant="ghost"
size="sm"
onClick={handlePauseToggle}
className="h-7 px-2"
>
{isPaused ? (
<Play className="w-3 h-3" />
) : (
<Pause className="w-3 h-3" />
)}
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
</Button>
)}
<Link to="/live-blogs">
<Button variant="ghost" size="sm" className="h-7 px-2">
<span className="text-xs">View All</span>
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</div>
</div>
{/* Ticker content */}
<div
ref={tickerRef}
className="relative h-12 overflow-hidden"
style={{
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
}}
>
<div
ref={contentRef}
className="absolute top-0 left-0 h-full flex items-center whitespace-nowrap"
style={{
transform: `translateX(-${scrollPosition}px)`,
transition: isPaused ? 'transform 0.3s ease' : 'none'
}}
>
{displayBlogs.map((blog, index) => (
<React.Fragment key={blog.id}>
<Link
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div>
<span className="font-medium text-sm group-hover:text-primary transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs">
{blog.updates.length} updates
</Badge>
)}
</div>
</Link>
{/* Separator (except after last item) */}
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment>
))}
{/* Duplicate content for seamless looping */}
{displayBlogs.map((blog, index) => (
<React.Fragment key={`${blog.id}-dup`}>
<Link
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div>
<span className="font-medium text-sm group-hover:text-primary transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs">
{blog.updates.length} updates
</Badge>
)}
</div>
</Link>
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment>
))}
</div>
</div>
{/* Progress indicator */}
{autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2">
<div className="h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,145 @@
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Pin,
PinOff,
Edit,
Trash2,
Clock,
User
} from 'lucide-react';
interface LiveBlogUpdateProps {
update: ApiLiveBlogUpdate;
isEditing?: boolean;
onEdit?: (update: ApiLiveBlogUpdate) => void;
onDelete?: (update: ApiLiveBlogUpdate) => void;
onPinToggle?: (update: ApiLiveBlogUpdate) => void;
showAdminControls?: boolean;
}
export function LiveBlogUpdate({
update,
isEditing = false,
onEdit,
onDelete,
onPinToggle,
showAdminControls = false,
}: LiveBlogUpdateProps) {
const isPinned = update.isPinned;
return (
<div
className={cn(
'relative p-4 rounded-lg border transition-all duration-200',
isPinned && 'border-primary bg-primary/5 shadow-sm',
!isPinned && 'border-border hover:border-muted-foreground/20'
)}
>
{/* Pinned indicator */}
{isPinned && (
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full flex items-center gap-1">
<Pin className="w-3 h-3" />
Pinned
</div>
)}
{/* Admin controls */}
{showAdminControls && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => onPinToggle?.(update)}
className="h-8 w-8 p-0"
>
{isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit?.(update)}
className="h-8 w-8 p-0"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete?.(update)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{update.author ? (
<>
{update.author.avatar ? (
<img
src={update.author.avatar}
alt={update.author.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div>
<div className="font-medium text-sm">{update.author.name}</div>
{update.author.slug && (
<div className="text-xs text-muted-foreground">@{update.author.slug}</div>
)}
</div>
</>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<User className="w-4 h-4" />
<span className="text-sm">Anonymous</span>
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{new Date(update.createdAt).toLocaleTimeString()}</span>
</div>
</div>
{/* Update content */}
<div className="prose prose-sm max-w-none">
{isEditing ? (
<textarea
defaultValue={update.content}
className="w-full p-2 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
rows={3}
autoFocus
/>
) : (
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{update.content}
</div>
)}
</div>
{/* Scheduled indicator */}
{update.scheduledAt && new Date(update.scheduledAt) > new Date() && (
<div className="mt-2 text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
Scheduled for: {new Date(update.scheduledAt).toLocaleString()}
</div>
)}
{/* Update metadata */}
<div className="mt-2 text-xs text-muted-foreground">
ID: {update.id.slice(0, 8)}
</div>
</div>
);
}
@@ -0,0 +1,301 @@
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';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface LiveBlogViewerProps {
slug: string;
className?: string;
}
export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
const [autoScroll, setAutoScroll] = useState(true);
const [newUpdatesCount, setNewUpdatesCount] = useState(0);
const [isScrolledUp, setIsScrolledUp] = useState(false);
const updatesContainerRef = useRef<HTMLDivElement>(null);
const lastUpdateCountRef = useRef(0);
const { data: liveBlog, isLoading: blogLoading, error: blogError } = useLiveBlog(slug);
const {
data: updatesData,
isLoading: updatesLoading,
refetch: refetchUpdates
} = useLiveBlogUpdates(liveBlog?.id || '', 1, 100);
const {
isConnected,
lastEvent,
connectionError,
reconnectAttempts,
connect
} = useLiveBlogStream(liveBlog?.id || '');
const scrollToBottom = useCallback(() => {
if (updatesContainerRef.current && autoScroll) {
const container = updatesContainerRef.current;
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
}, [autoScroll]);
const handleScroll = useCallback(() => {
if (!updatesContainerRef.current) return;
const container = updatesContainerRef.current;
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50;
setIsScrolledUp(!isAtBottom);
if (isAtBottom) {
setNewUpdatesCount(0);
}
}, []);
const handleAutoScrollToggle = useCallback(() => {
setAutoScroll(!autoScroll);
if (!autoScroll) {
scrollToBottom();
setNewUpdatesCount(0);
}
}, [autoScroll, scrollToBottom]);
const showNewUpdates = useCallback(() => {
setNewUpdatesCount(0);
scrollToBottom();
setIsScrolledUp(false);
}, [scrollToBottom]);
// Handle new updates from SSE
useEffect(() => {
if (lastEvent?.type === 'update') {
const handleUpdate = () => {
refetchUpdates();
if (autoScroll && !isScrolledUp) {
// Add a small delay to ensure the update is in the DOM
setTimeout(() => {
scrollToBottom();
}, 100);
} else if (!autoScroll || isScrolledUp) {
setNewUpdatesCount(prev => prev + 1);
}
};
handleUpdate();
}
}, [lastEvent, autoScroll, isScrolledUp, refetchUpdates, scrollToBottom]);
// Initial scroll to bottom when data loads
useEffect(() => {
if (updatesData?.data && updatesData.data.length > 0) {
const currentUpdateCount = updatesData.data.length;
// Only auto-scroll if this is the initial load or user is at bottom
if (currentUpdateCount > lastUpdateCountRef.current && !isScrolledUp) {
setTimeout(scrollToBottom, 100);
}
lastUpdateCountRef.current = currentUpdateCount;
}
}, [updatesData, scrollToBottom, isScrolledUp]);
if (blogLoading) {
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="space-y-2">
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
</div>
</div>
</CardContent>
</Card>
);
}
if (blogError || !liveBlog) {
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardContent className="p-6">
<div className="text-center text-destructive">
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
<p className="text-sm">
{blogError instanceof Error ? blogError.message : 'Unknown error occurred'}
</p>
</div>
</CardContent>
</Card>
);
}
const updates = updatesData?.data || [];
const isLive = liveBlog.status === 'live';
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="text-2xl mb-2">{liveBlog.title}</CardTitle>
{liveBlog.description && (
<p className="text-muted-foreground">{liveBlog.description}</p>
)}
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
isLive ? 'bg-green-500' : 'bg-gray-400'
)} />
<span>{isLive ? 'Live' : 'Ended'}</span>
</div>
{isLive && (
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
isConnected ? 'bg-green-500' : 'bg-red-500'
)} />
<span>
{isConnected ? 'Connected' : `Reconnecting... (${reconnectAttempts})`}
</span>
</div>
)}
<span>{liveBlog.viewCount} views</span>
<span>{updates.length} updates</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={autoScroll ? 'default' : 'outline'}
size="sm"
onClick={handleAutoScrollToggle}
>
{autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF'}
</Button>
{!isConnected && (
<Button
variant="outline"
size="sm"
onClick={connect}
>
Reconnect
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{connectionError && (
<div className="m-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center justify-between">
<p className="text-sm text-destructive">
{connectionError}
</p>
<Button
variant="outline"
size="sm"
onClick={connect}
>
Try Again
</Button>
</div>
</div>
)}
<div
ref={updatesContainerRef}
className="max-h-[600px] overflow-y-auto px-6 pb-6"
onScroll={handleScroll}
>
{updates.length === 0 && !updatesLoading ? (
<div className="text-center py-12 text-muted-foreground">
<p>No updates yet. Check back soon!</p>
</div>
) : (
<div className="space-y-4">
{updates.map((update) => (
<LiveBlogUpdate key={update.id} update={update} />
))}
{updatesLoading && (
<div className="animate-pulse">
<div className="h-16 bg-muted rounded"></div>
</div>
)}
</div>
)}
</div>
{/* New updates indicator */}
{isScrolledUp && newUpdatesCount > 0 && (
<div className="sticky bottom-0 bg-background border-t p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{newUpdatesCount} new {newUpdatesCount === 1 ? 'update' : 'updates'}
</span>
<Button size="sm" onClick={showNewUpdates}>
Show Updates
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface LiveBlogUpdateProps {
update: ApiLiveBlogUpdate;
}
function LiveBlogUpdate({ update }: LiveBlogUpdateProps) {
const isPinned = update.isPinned;
return (
<div
className={cn(
'relative p-4 rounded-lg border',
isPinned && 'border-primary bg-primary/5'
)}
>
{isPinned && (
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full">
Pinned
</div>
)}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{update.author && (
<>
{update.author.avatar && (
<img
src={update.author.avatar}
alt={update.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-sm font-medium">{update.author.name}</span>
</>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(update.createdAt).toLocaleTimeString()}
</span>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{update.content}
</div>
</div>
);
}
@@ -0,0 +1,238 @@
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,
Eye,
Pin,
Play,
Square,
ChevronRight
} from 'lucide-react';
interface PinnedLiveBlogSidebarProps {
className?: string;
maxItems?: number;
}
export function PinnedLiveBlogSidebar({
className = '',
maxItems = 3
}: PinnedLiveBlogSidebarProps) {
const { data: pinnedBlogs, isLoading, error } = usePinnedLiveBlogs();
if (isLoading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Pinned Live Blogs
</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>
);
}
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>
);
}
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) {
case 'live': return 'bg-green-100 text-green-800 border-green-200';
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'live': return <Play className="w-3 h-3" />;
case 'ended': return <Square className="w-3 h-3" />;
default: return null;
}
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffMins < 1440) {
return `${Math.floor(diffMins / 60)}h ago`;
} else {
return `${Math.floor(diffMins / 1440)}d ago`;
}
};
const getLatestUpdate = (blog: LiveBlog) => {
if (!blog.updates || blog.updates.length === 0) return null;
return blog.updates[blog.updates.length - 1];
};
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Live Coverage
</CardTitle>
<Badge variant="outline" className="text-xs">
{(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);
return (
<Link
key={blog.id}
to="/live-blogs/$slug"
params={{ slug: blog.slug }}
className="block p-3 rounded-lg border hover:bg-accent/50 transition-colors group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
{blog.title}
</h4>
{blog.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{blog.description}
</p>
)}
</div>
<Badge
className={`${getStatusColor(blog.status)} text-xs`}
variant="outline"
>
<div className="flex items-center gap-1">
{getStatusIcon(blog.status)}
{blog.status}
</div>
</Badge>
</div>
{/* Latest update preview */}
{latestUpdate && (
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Clock className="w-3 h-3" />
<span>{formatTime(latestUpdate.createdAt)}</span>
</div>
<p className="line-clamp-2">{latestUpdate.content}</p>
</div>
)}
{/* Stats */}
<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} updates</span>
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<span>{blog.viewCount} views</span>
</div>
</div>
</Link>
);
})}
</div>
{/* View all button */}
{(pinnedBlogs || []).length > maxItems && (
<Button
variant="ghost"
size="sm"
className="w-full mt-4"
asChild
>
<Link to="/live-blogs">
View all pinned blogs
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
</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>
);
}
@@ -0,0 +1,186 @@
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({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading article</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Article not found</div>
</div>
)
}
return (
<article className="max-w-3xl mx-auto">
<Link
to="/articles"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
<path d="M19 6H5" />
</svg>
Back to articles
</Link>
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
<span>
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{data.views} views</span>
{data.author && (
<>
<span>•</span>
<span>By {data.author.name}</span>
</>
)}
</div>
{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={`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">
<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 && (
<div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2">
{data.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
</div>
)}
</article>
)
}
@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticlesComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={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">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,39 @@
import { CreateLiveBlog } from '@/components/admin/live-blog/CreateLiveBlog';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Link } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
export function CreateLiveBlogComponent() {
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<Link to="/live-blogs">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Live Blogs
</Button>
</Link>
</div>
<CreateLiveBlog
onSuccess={() => {
// Success is handled by navigation in the component
}}
/>
<Card className="mt-6">
<CardContent className="p-6">
<h3 className="font-medium mb-2">Tips for creating a successful live blog:</h3>
<ul className="text-sm text-muted-foreground space-y-1 list-disc pl-5">
<li>Choose a clear, descriptive title that tells readers what to expect</li>
<li>Use a URL-friendly slug (lowercase, hyphens, no spaces)</li>
<li>Start with a draft status to prepare content before going live</li>
<li>Use the description to provide context for readers</li>
<li>Remember: you can always edit these details later</li>
</ul>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,12 @@
import { LiveBlogManager } from '@/components/admin/live-blog/LiveBlogManager'
export function LiveBlogAdminComponent({ slug }: { slug: string }) {
return (
<div className="py-8">
<LiveBlogManager
slug={slug}
onBack={() => window.history.back()}
/>
</div>
)
}
@@ -0,0 +1,5 @@
import { LiveBlogViewer } from '@/components/features/live-blog/LiveBlogViewer'
export function LiveBlogDetailComponent({ slug }: { slug: string }) {
return <LiveBlogViewer slug={slug} className="py-8" />
}
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { LiveBlogTicker } from '@/components/features/live-blog/LiveBlogTicker'
export function LiveBlogsComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['liveBlogs'],
queryFn: () => api.fetchLiveBlogs({ status: 'live' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading live blogs...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading live blogs</div>
</div>
)
}
return (
<div className="space-y-6">
<LiveBlogTicker />
<div className="mb-8">
<h1 className="text-3xl font-bold">Live Blogs</h1>
<p className="text-muted-foreground">Breaking news and live updates</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data?.data.map((liveBlog) => (
<Link
key={liveBlog.id}
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">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm text-muted-foreground">
{liveBlog.status === 'live' ? 'LIVE NOW' : liveBlog.status.toUpperCase()}
</span>
</div>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{liveBlog.title}
</h2>
{liveBlog.description && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{liveBlog.description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>{liveBlog.viewCount} views</span>
<span>{liveBlog.updates?.length || 0} updates</span>
</div>
<span>
{new Date(liveBlog.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{liveBlog.author && (
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
{liveBlog.author.avatar && (
<img
src={liveBlog.author.avatar}
alt={liveBlog.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-sm text-muted-foreground">
By {liveBlog.author.name}
</span>
</div>
)}
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No live blogs are currently active. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { badgeVariants } from "./badge-variants"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge }
@@ -0,0 +1,30 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
+3 -31
View File
@@ -1,37 +1,9 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -53,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
@@ -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>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from 'react';
export interface LiveBlogStreamEvent {
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
data: unknown;
clientId?: string;
}
export interface LiveBlogStreamOptions {
autoReconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
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<ReturnType<typeof setTimeout> | null>(null);
const lastEventIdRef = useRef<string | null>(null);
const optionsRef = useRef(mergedOptions);
const reconnectAttemptsRef = useRef(reconnectAttempts);
// Update refs when props change
useEffect(() => {
optionsRef.current = mergedOptions;
}, [mergedOptions]);
useEffect(() => {
reconnectAttemptsRef.current = reconnectAttempts;
}, [reconnectAttempts]);
useEffect(() => {
if (!liveBlogId) return;
const disconnect = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
setIsConnected(false);
setReconnectAttempts(0);
console.log('Disconnected from live blog stream');
};
const createConnection = () => {
// Close existing connection if any
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
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);
}
try {
eventSourceRef.current = new EventSource(url.toString());
eventSourceRef.current.onopen = () => {
setIsConnected(true);
setConnectionError(null);
setReconnectAttempts(0);
console.log(`Connected to live blog stream for ${liveBlogId}`);
};
eventSourceRef.current.onmessage = (event) => {
try {
const parsedEvent: LiveBlogStreamEvent = JSON.parse(event.data);
setLastEvent(parsedEvent);
// Store last event ID for reconnection
if (event.lastEventId) {
lastEventIdRef.current = event.lastEventId;
}
// Handle different event types
switch (parsedEvent.type) {
case 'connected':
console.log('Stream connected:', parsedEvent.clientId);
break;
case 'update':
console.log('New update received:', parsedEvent.data);
break;
case 'status-change':
console.log('Status change received:', parsedEvent.data);
break;
case 'pin-update':
console.log('Pin update received:', parsedEvent.data);
break;
default:
console.log('Unknown event type:', parsedEvent.type);
}
} catch (error) {
console.error('Error parsing SSE event:', error);
setLastEvent({
type: 'error',
data: 'Failed to parse server event',
});
}
};
eventSourceRef.current.onerror = () => {
console.error('SSE connection error');
setIsConnected(false);
setConnectionError('Connection to live blog lost');
// Attempt reconnection if enabled and within limits
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 || 10})`);
createConnection();
}, optionsRef.current.reconnectInterval || 3000);
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
setConnectionError('Failed to reconnect after multiple attempts');
}
};
} catch (error) {
console.error('Failed to create EventSource connection:', error);
setConnectionError('Failed to connect to live blog stream');
setIsConnected(false);
}
};
createConnection();
return () => {
disconnect();
};
}, [liveBlogId]);
const manualReconnect = () => {
setReconnectAttempts(0);
setConnectionError(null);
// Trigger reconnection by updating liveBlogId dependency
// This will cause the useEffect to run again
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// The useEffect will automatically reconnect
};
return {
isConnected,
lastEvent,
connectionError,
reconnectAttempts,
connect: manualReconnect,
disconnect: () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsConnected(false);
},
};
}
+401 -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,19 +50,61 @@ 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();
// Convert parameters to proper types for URLSearchParams
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
if (value !== undefined && value !== null) {
if (typeof value === 'number') {
searchParams.append(key, value.toString());
} else {
searchParams.append(key, String(value));
}
}
});
const url = `${API_BASE_URL}/articles?${searchParams}`;
@@ -86,3 +137,351 @@ 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;
content: string;
isPinned: boolean;
authorId: string | null;
scheduledAt: string | null;
strapiId: string | null;
author?: {
id: string;
name: string;
slug: string;
bio: string | null;
avatar: string | null;
};
createdAt: string;
updatedAt: string;
}
export interface LiveBlog {
id: string;
title: string;
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;
name: string;
slug: string;
bio: string | null;
avatar: string | null;
};
category?: {
id: string;
name: string;
slug: string;
description: string | null;
};
updates?: LiveBlogUpdate[];
createdAt: string;
updatedAt: string;
}
export interface LiveBlogsResponse {
data: LiveBlog[];
total: number;
page?: number;
limit?: number;
}
export interface LiveBlogUpdatesResponse {
data: LiveBlogUpdate[];
total: number;
page?: number;
limit?: number;
}
export interface FindLiveBlogsParams {
category?: string;
author?: string;
status?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateLiveBlogUpdateDto {
content: string;
isPinned?: boolean;
authorId?: string;
scheduledAt?: string;
strapiId?: string;
}
export interface UpdateLiveBlogUpdateDto {
content?: string;
isPinned?: boolean;
authorId?: string;
scheduledAt?: string;
strapiId?: string;
}
export interface CreateLiveBlogDto {
title: string;
slug?: string;
description?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
authorId?: string;
categoryId?: string;
strapiId?: string;
}
export interface UpdateLiveBlogDto {
title?: string;
slug?: string;
description?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
isPinned?: boolean;
authorId?: string;
categoryId?: string;
strapiId?: string;
}
// Live Blog API Functions
export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<LiveBlogsResponse> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === 'number') {
searchParams.append(key, value.toString());
} else {
searchParams.append(key, String(value));
}
}
});
const response = await fetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
if (!response.ok) {
throw new Error('Failed to fetch live blogs');
}
return response.json();
}
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
if (!response.ok) {
throw new Error('Failed to fetch live blog');
}
return response.json();
}
export async function fetchLiveBlogById(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch live blog');
}
return response.json();
}
export async function fetchLiveBlogUpdates(
liveBlogId: string,
page = 1,
limit = 50
): Promise<LiveBlogUpdatesResponse> {
const response = await fetch(
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
);
if (!response.ok) {
throw new Error('Failed to fetch live blog updates');
}
return response.json();
}
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/recent`);
if (!response.ok) {
throw new Error('Failed to fetch recent live blogs');
}
return response.json();
}
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/featured`);
if (!response.ok) {
throw new Error('Failed to fetch pinned live blogs');
}
return response.json();
}
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/active`);
if (!response.ok) {
throw new Error('Failed to fetch active live blogs');
}
return response.json();
}
// Admin functions
export async function createLiveBlogUpdate(
liveBlogId: string,
dto: CreateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create live blog update');
}
return response.json();
}
export async function updateLiveBlogUpdate(
liveBlogId: string,
updateId: string,
dto: UpdateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update live blog update');
}
return response.json();
}
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete live blog update');
}
}
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create live blog');
}
return response.json();
}
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update live blog');
}
return response.json();
}
export async function deleteLiveBlog(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
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] });
},
});
}
+185
View File
@@ -0,0 +1,185 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
// Live Blog Queries
export function useLiveBlogs(params: api.FindLiveBlogsParams = {}) {
return useQuery({
queryKey: ['liveBlogs', params],
queryFn: () => api.fetchLiveBlogs(params),
});
}
export function useLiveBlog(slug: string) {
return useQuery({
queryKey: ['liveBlog', slug],
queryFn: () => api.fetchLiveBlogBySlug(slug),
enabled: !!slug,
refetchOnWindowFocus: true,
refetchOnMount: true,
});
}
export function useLiveBlogById(id: string) {
return useQuery({
queryKey: ['liveBlog', id],
queryFn: () => api.fetchLiveBlogById(id),
enabled: !!id,
});
}
export function useLiveBlogUpdates(liveBlogId: string, page = 1, limit = 50) {
return useQuery({
queryKey: ['liveBlogUpdates', liveBlogId, page, limit],
queryFn: () => api.fetchLiveBlogUpdates(liveBlogId, page, limit),
enabled: !!liveBlogId,
refetchInterval: 10000, // Poll every 10 seconds as fallback
});
}
export function useRecentLiveBlogs() {
return useQuery({
queryKey: ['recentLiveBlogs'],
queryFn: () => api.fetchRecentLiveBlogs(),
refetchInterval: 30000, // Refetch every 30 seconds
});
}
export function usePinnedLiveBlogs() {
return useQuery({
queryKey: ['pinnedLiveBlogs'],
queryFn: () => api.fetchPinnedLiveBlogs(),
refetchInterval: 30000, // Refetch every 30 seconds
});
}
export function useActiveLiveBlogs() {
return useQuery({
queryKey: ['activeLiveBlogs'],
queryFn: () => api.fetchActiveLiveBlogs(),
refetchInterval: 10000, // Refetch every 10 seconds for ticker
});
}
// Live Blog Mutations (Admin)
export function useCreateLiveBlogUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
api.createLiveBlogUpdate(liveBlogId, dto),
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
},
});
}
export function useUpdateLiveBlogUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ liveBlogId, updateId, dto }: {
liveBlogId: string;
updateId: string;
dto: api.UpdateLiveBlogUpdateDto
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
},
});
}
export function useDeleteLiveBlogUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
api.deleteLiveBlogUpdate(liveBlogId, updateId),
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
},
});
}
// Live Blog CRUD Mutations
export function useCreateLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: api.CreateLiveBlogDto) => api.createLiveBlog(dto),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
export function useUpdateLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) =>
api.updateLiveBlog(id, dto),
onSuccess: () => {
// Force immediate refetch of all live blog queries
queryClient.invalidateQueries({
queryKey: ['liveBlog'],
refetchType: 'active'
});
queryClient.invalidateQueries({
queryKey: ['liveBlogs'],
refetchType: 'active'
});
queryClient.invalidateQueries({
queryKey: ['recentLiveBlogs'],
refetchType: 'active'
});
},
});
}
export function useDeleteLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteLiveBlog(id),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
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'] });
},
});
}
+137 -172
View File
@@ -1,6 +1,14 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import * as api from './lib/api'
import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
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'
const rootRoute = createRootRoute({
@@ -26,6 +34,15 @@ const rootRoute = createRootRoute({
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
<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>
</nav>
</div>
</header>
@@ -47,8 +64,20 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div className="py-12 md:py-20">
<div className="max-w-4xl mx-auto text-center mb-12">
<div className="space-y-6">
{/* Article Ticker at the top */}
<ArticleTicker />
{/* Live Blog Ticker below article ticker */}
<LiveBlogTicker className="mt-4" />
<div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main content - 3 columns */}
<div className="lg:col-span-3 space-y-8">
{/* Hero section */}
<div className="rounded-xl border bg-card p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
@@ -66,7 +95,8 @@ const indexRoute = createRoute({
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{/* Features grid */}
<div className="grid md:grid-cols-3 gap-6">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
@@ -105,24 +135,72 @@ const indexRoute = createRoute({
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Community</h3>
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
<p className="text-muted-foreground text-sm">
Join thousands of readers who appreciate the finer art of Macedonian sarcasm.
Real-time updates on breaking news with our live blogging system. No delays, just facts.
</p>
</div>
</div>
<div className="mt-16 text-center">
{/* Call to action */}
<div className="rounded-xl border bg-card p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
Dive into our articles or follow live coverage of breaking news as it happens.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/articles"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
>
Browse Articles
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
Read Articles
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
<Link
to="/live-blogs"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
>
View Live Blogs
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</Link>
</div>
</div>
</div>
{/* Sidebar - 1 column */}
<div className="lg:col-span-1">
<PinnedLiveBlogSidebar />
{/* Additional sidebar content */}
<div className="mt-6 rounded-xl border bg-card p-6">
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
<p className="text-sm text-muted-foreground mb-4">
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
</p>
<div className="text-xs text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Live coverage updated in real-time</span>
</div>
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>No ads, no sponsors, no BS</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span>100% independent journalism</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
),
@@ -131,74 +209,7 @@ const indexRoute = createRoute({
const articlesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/articles',
component: () => {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={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">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
},
component: ArticlesComponent,
})
const articleDetailRoute = createRoute({
@@ -206,101 +217,56 @@ const articleDetailRoute = createRoute({
path: '/articles/$id',
component: () => {
const { id } = articleDetailRoute.useParams()
const { data, isLoading, error } = useQuery({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading article</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Article not found</div>
</div>
)
}
return (
<article className="max-w-3xl mx-auto">
<Link
to="/articles"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
<path d="M19 6H5" />
</svg>
Back to articles
</Link>
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
<span>
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{data.views} views</span>
{data.author && (
<>
<span>•</span>
<span>By {data.author.name}</span>
</>
)}
</div>
{data.featuredImage && (
<img
src={data.featuredImage}
alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
/>
)}
<div className="prose prose-slate max-w-none">
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2">
{data.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
</div>
)}
</article>
)
return <ArticleDetailComponent id={id} />
},
})
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute, articleDetailRoute])
const liveBlogsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/live-blogs',
component: LiveBlogsComponent,
})
const liveBlogDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/live-blogs/$slug',
component: () => {
const { slug } = liveBlogDetailRoute.useParams()
return <LiveBlogDetailComponent slug={slug} />
},
})
const liveBlogAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/live-blogs/$slug',
component: () => {
const { slug } = liveBlogAdminRoute.useParams()
return <LiveBlogAdminComponent slug={slug} />
},
})
const createLiveBlogRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/live-blogs/create',
component: CreateLiveBlogComponent,
})
const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: AdminDashboardComponent,
})
const routeTree = rootRoute.addChildren([
indexRoute,
articlesRoute,
articleDetailRoute,
liveBlogsRoute,
liveBlogDetailRoute,
liveBlogAdminRoute,
createLiveBlogRoute,
adminDashboardRoute,
])
export const router = createRouter({ routeTree })
@@ -309,4 +275,3 @@ declare module '@tanstack/react-router' {
router: typeof router
}
}
+351
View File
@@ -0,0 +1,351 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import * as api from './lib/api'
import './styles.css'
function ArticleTicker() {
const { data } = useQuery({
queryKey: ['ticker-articles'],
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
})
const articles = data?.data.slice(0, 10) || []
if (articles.length === 0) return null
return (
<div className="overflow-hidden bg-muted/50 border-y">
<div className="container mx-auto max-w-6xl px-4">
<div className="py-2 flex items-center gap-4">
<span className="text-sm font-semibold text-primary whitespace-nowrap">
Latest:
</span>
<div className="overflow-hidden flex-1">
<div className="flex animate-marquee">
{articles.map((article) => (
<div key={article.id} className="whitespace-nowrap px-6 border-r">
<Link
to={`/articles/${article.id}` as any}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{article.title}
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
const rootRoute = createRootRoute({
head: () => ({
meta: [
{
title: 'Placebo.mk - Sarcastic News from Macedonia',
description: 'Latest news and articles from Macedonia with a sarcastic twist',
},
],
}),
component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col">
<header className="border-b">
<div className="container mx-auto max-w-6xl px-4 py-4">
<h1 className="text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link>
</h1>
<nav className="flex gap-4">
<Link to="/" className="text-sm font-medium hover:underline">
Home
</Link>
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
</nav>
</div>
</header>
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet />
</main>
<footer className="border-t mt-12">
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground">
© 2025 Placebo.mk. Sarcastic news from Macedonia.
</div>
</footer>
</div>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div>
<ArticleTicker />
<div className="py-12 md:py-20">
<div className="max-w-4xl mx-auto text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
Placebo<span className="text-primary">.mk</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
<p className="text-muted-foreground text-sm">
Freshly brewed sarcasm on current events, politics, and everything in between.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
<p className="text-muted-foreground text-sm">
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Community</h3>
<p className="text-muted-foreground text-sm">
Join thousands of readers who appreciate the finer art of Macedonian sarcasm.
</p>
</div>
</div>
<div className="mt-16 text-center">
<Link
to="/articles"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
>
Browse Articles
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</div>
</div>
),
})
const articlesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/articles',
component: () => {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={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">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
},
})
const articleDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/articles/$id',
component: () => {
const { id } = articleDetailRoute.useParams()
const { data, isLoading, error } = useQuery({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading article</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Article not found</div>
</div>
)
}
return (
<article className="max-w-3xl mx-auto">
<Link
to="/articles"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
<path d="M19 6H5" />
</svg>
Back to articles
</Link>
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
<span>
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{data.views} views</span>
{data.author && (
<>
<span>•</span>
<span>By {data.author.name}</span>
</>
)}
</div>
{data.featuredImage && (
<img
src={data.featuredImage}
alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
/>
)}
<div className="prose prose-slate max-w-none">
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2">
{data.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
</div>
)}
</article>
)
},
})
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute, articleDetailRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
+15
View File
@@ -67,6 +67,15 @@
box-sizing: border-box;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@layer base {
* {
border-color: hsl(var(--border));
@@ -75,4 +84,10 @@
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.animate-marquee {
display: flex;
animation: marquee 30s linear infinite;
width: max-content;
}
}
+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