Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3bc2a014 | ||
|
|
acfbc80d1a | ||
|
|
0c2433cac6 | ||
|
|
7c7bb45446 | ||
|
|
2ff76ffda5 | ||
|
|
4ccb65ba88 | ||
|
|
5a457fa99f | ||
|
|
0351726eeb | ||
|
|
e8893c1aae |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
Generated
+159
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
} from 'class-validator';
|
||||
import { ArticleStatus } from './entities';
|
||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
||||
|
||||
export class CreateArticleDto {
|
||||
@IsString()
|
||||
@@ -118,6 +120,143 @@ export class FindArticlesDto {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
@@ -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,13 @@ export enum ArticleStatus {
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum LiveBlogStatus {
|
||||
DRAFT = 'draft',
|
||||
LIVE = 'live',
|
||||
ENDED = 'ended',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
@Entity('authors')
|
||||
export class Author {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -145,3 +153,93 @@ 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 })
|
||||
strapiId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
authorId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
categoryId: 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;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
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';
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||
EventEmitterModule.forRoot(),
|
||||
],
|
||||
controllers: [LiveBlogController],
|
||||
providers: [LiveBlogService],
|
||||
exports: [LiveBlogService],
|
||||
})
|
||||
export class LiveBlogModule {}
|
||||
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} 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 {
|
||||
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,
|
||||
) {}
|
||||
|
||||
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 });
|
||||
}
|
||||
} else if (!isPinned) {
|
||||
// Default to live blogs if no status specified AND not querying for pinned blogs
|
||||
// Pinned blogs can be either live or ended
|
||||
queryBuilder.where('liveBlog.status = :status', {
|
||||
status: LiveBlogStatus.LIVE,
|
||||
});
|
||||
}
|
||||
|
||||
if (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,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedBlog;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
await this.liveBlogRepository.remove(liveBlog);
|
||||
}
|
||||
|
||||
// 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 (error) {
|
||||
// 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> {
|
||||
let liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
liveBlog = this.liveBlogRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || LiveBlogStatus.DRAFT,
|
||||
});
|
||||
} else {
|
||||
Object.assign(liveBlog, data);
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(liveBlog);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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 +11,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' };
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ 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, ArticlesModule, LiveBlogModule],
|
||||
controllers: [StrapiController],
|
||||
providers: [StrapiService],
|
||||
exports: [StrapiService],
|
||||
|
||||
@@ -3,8 +3,9 @@ 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 } from './entities';
|
||||
|
||||
interface StrapiArticle {
|
||||
id: number;
|
||||
@@ -18,6 +19,18 @@ interface StrapiArticle {
|
||||
updatedAt: 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;
|
||||
}
|
||||
|
||||
interface StrapiResponse<T> {
|
||||
data: T;
|
||||
meta: {
|
||||
@@ -40,6 +53,7 @@ export class StrapiService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly articlesService: ArticlesService,
|
||||
private readonly liveBlogService: LiveBlogService,
|
||||
) {
|
||||
this.strapiUrl =
|
||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
||||
@@ -101,9 +115,12 @@ 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>>(
|
||||
@@ -115,14 +132,26 @@ 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 articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
content: strapiArticle.content,
|
||||
slug: strapiArticle.slug,
|
||||
status: strapiArticle.publishedAt
|
||||
? ArticleStatus.PUBLISHED
|
||||
: ArticleStatus.DRAFT,
|
||||
status,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
@@ -130,27 +159,181 @@ export class StrapiService {
|
||||
strapiArticle.documentId,
|
||||
articleData,
|
||||
);
|
||||
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync article ${strapiId} from Strapi`,
|
||||
error,
|
||||
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`);
|
||||
} catch (error: any) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
if (event === 'entry.unpublish' && error.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,
|
||||
);
|
||||
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`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const strapiLiveBlogs = response.data.data;
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
||||
};
|
||||
|
||||
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 handleWebhook(
|
||||
event: 'entry.create' | 'entry.update' | 'entry.delete',
|
||||
data: { documentId: string },
|
||||
async syncSingleLiveBlog(
|
||||
strapiId: string,
|
||||
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received webhook event: ${event}`);
|
||||
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}`,
|
||||
{
|
||||
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 liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
strapiLiveBlog.documentId,
|
||||
liveBlogData,
|
||||
);
|
||||
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`);
|
||||
} catch (error: any) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
if (event === 'entry.unpublish' && error.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' | 'entry.publish' | 'entry.unpublish',
|
||||
data: { documentId: string; model?: string },
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Received webhook event: ${event} for model: ${data.model}`,
|
||||
);
|
||||
|
||||
if (event === 'entry.delete') {
|
||||
this.logger.log(`Handling delete for document: ${data.documentId}`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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,16 @@
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"img": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files",
|
||||
"videos",
|
||||
"audios"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -446,6 +446,7 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1251
-1
File diff suppressed because it is too large.
Load diff
@@ -12,13 +12,21 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
+4327
File diff suppressed because it is too large.
Load diff
@@ -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,260 @@
|
||||
import { useLiveBlogs } from '@/queries/live-blogs';
|
||||
import { useArticles } from '@/queries/articles';
|
||||
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() {
|
||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 });
|
||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 });
|
||||
|
||||
const liveBlogs = liveBlogsData?.data || [];
|
||||
const articles = articlesData?.data || [];
|
||||
|
||||
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 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>Live блогови</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{liveBlogs.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>
|
||||
) : liveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">Нема live блогови</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{liveBlogs.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/${blog.slug}` as any}
|
||||
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/${blog.slug}` as any}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Articles Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Написи</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{articles.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>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">Нема написи</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/">Креирај нов напис</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{articles.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/${article.id}` as any}
|
||||
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/${article.id}` as any}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to={`/articles/${article.id}` as any} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,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) {
|
||||
const nextAttempt = reconnectAttemptsRef.current + 1;
|
||||
setReconnectAttempts(nextAttempt);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`);
|
||||
createConnection();
|
||||
}, optionsRef.current.reconnectInterval);
|
||||
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+277
-2
@@ -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;
|
||||
@@ -50,9 +54,15 @@ export interface FindArticlesParams {
|
||||
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) {
|
||||
searchParams.append(key, String(value));
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
searchParams.append(key, value.toString());
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,3 +96,268 @@ export async function fetchArticleById(id: string): Promise<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;
|
||||
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?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
+210
-245
@@ -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({
|
||||
@@ -19,14 +27,23 @@ const rootRoute = createRootRoute({
|
||||
<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>
|
||||
<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>
|
||||
<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,82 +64,143 @@ 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="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="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" />
|
||||
<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>
|
||||
{/* 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">
|
||||
<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">Live Coverage</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 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"
|
||||
>
|
||||
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>
|
||||
<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>
|
||||
),
|
||||
@@ -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 })
|
||||
|
||||
@@ -308,5 +274,4 @@ declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,9 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true, // Listen on all addresses
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Generated
+29003
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
]
|
||||
}
|
||||
Executable
+24
@@ -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"
|
||||
Executable
+41
@@ -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"
|
||||
Executable
+34
@@ -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!"
|
||||
@@ -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');
|
||||
@@ -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
|
||||
Executable
+83
@@ -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"
|
||||
Executable
+147
@@ -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"
|
||||
Executable
+42
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
Live-blog webhooks work correctly: POST /api/v1/webhooks/strapi/live-blog
|
||||
Reference in new issue
Block a user