Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
000ebd388a | ||
|
|
3374eb1ec0 | ||
|
|
d7b82c0ec9 | ||
|
|
3718882f5b | ||
|
|
cb2355adcd | ||
|
|
c6e602d354 | ||
|
|
42002f8e6f | ||
|
|
7378d37b36 | ||
|
|
9ca05f5ea1 | ||
|
|
89b8687431 | ||
|
|
3bc534489d | ||
|
|
6467e21019 | ||
|
|
28609a6492 | ||
|
|
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
|
||||||
@@ -8,39 +8,75 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
|
|||||||
- **Backend**: NestJS + TypeORM + SQLite
|
- **Backend**: NestJS + TypeORM + SQLite
|
||||||
- **CMS**: Strapi (in /cms)
|
- **CMS**: Strapi (in /cms)
|
||||||
|
|
||||||
|
## Root-Level Commands
|
||||||
|
```bash
|
||||||
|
# Docker development
|
||||||
|
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 dev:docker # Start docker dev (alias)
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
npm run dev:local # Start backend & frontend locally
|
||||||
|
npm run dev:backend:local # Backend only (local env)
|
||||||
|
npm run dev:frontend:local # Frontend only (local env)
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
npm run lint # Lint both projects
|
||||||
|
npm run lint:fix # Auto-fix lint issues
|
||||||
|
npm run type-check # Type check both projects
|
||||||
|
|
||||||
|
# Database operations
|
||||||
|
npm run db:backup # Backup database
|
||||||
|
npm run db:restore # Restore database
|
||||||
|
npm run db:reset # Reset database
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
npm run reset:env # Reset to docker environment
|
||||||
|
```
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
### Backend (NestJS)
|
### Backend (NestJS)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm install
|
npm install
|
||||||
npm run start:dev # Watch mode
|
npm run start:dev # Watch mode
|
||||||
|
npm run start:debug # Debug mode
|
||||||
npm run build
|
npm run build
|
||||||
npm run start:prod
|
npm run start:prod
|
||||||
npm run lint # Lint
|
npm run lint # Lint
|
||||||
npm run lint:fix # Auto-fix
|
npm run lint:fix # Auto-fix
|
||||||
npm run type-check # TypeScript check
|
npm run type-check # TypeScript check
|
||||||
npm test # All tests
|
npm test # All tests
|
||||||
|
npm test:watch # Watch mode tests
|
||||||
|
npm test:cov # Coverage
|
||||||
|
npm test:e2e # E2E tests
|
||||||
npm test app.service.spec.ts # Single file
|
npm test app.service.spec.ts # Single file
|
||||||
npm test -t "should" # By test name
|
npm test -t "should return" # By test name pattern
|
||||||
npm run test:cov # Coverage
|
npm run format # Format with Prettier
|
||||||
npm run test:e2e # E2E tests
|
npm run dev:local # Local env with .env.local
|
||||||
|
npm run dev:docker # Docker env with .env.docker
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (TanStack)
|
### Frontend (TanStack)
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite dev server
|
npm run dev # Vite dev server
|
||||||
npm run build
|
npm run build
|
||||||
npm run preview
|
npm run preview
|
||||||
npm run lint
|
npm run lint # Lint
|
||||||
npm run lint:fix
|
npm run lint:fix # Auto-fix
|
||||||
npm run type-check
|
npm run type-check # TypeScript check
|
||||||
npm test # All tests
|
npm test # All tests (Vitest)
|
||||||
npm test Header.test.tsx # Single file
|
npm test:ui # Vitest UI
|
||||||
npm test -t "renders" # By name
|
npm test:coverage # Test coverage
|
||||||
npm run test:ui # Vitest UI
|
npm test Header.test.tsx # Single file
|
||||||
|
npm test -t "renders" # By test name pattern
|
||||||
|
npm run dev:local # Local env with .env.local
|
||||||
|
npm run dev:docker # Docker env with .env.docker
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
@@ -51,6 +87,8 @@ npm run test:ui # Vitest UI
|
|||||||
- Explicit return types for public methods
|
- Explicit return types for public methods
|
||||||
- Prefer interfaces over types for objects
|
- Prefer interfaces over types for objects
|
||||||
- Use `readonly` for immutable properties
|
- Use `readonly` for immutable properties
|
||||||
|
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
|
||||||
|
- Frontend: Path alias `@/*` maps to `./src/*`
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
|
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
|
||||||
@@ -62,6 +100,20 @@ npm run test:ui # Vitest UI
|
|||||||
|
|
||||||
### Import Order
|
### Import Order
|
||||||
- External libraries first, then internal modules, then relative imports
|
- External libraries first, then internal modules, then relative imports
|
||||||
|
- Group imports with blank lines between groups
|
||||||
|
- Example:
|
||||||
|
```typescript
|
||||||
|
// External
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
// Internal modules
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
// Relative imports
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### File Structure
|
### File Structure
|
||||||
@@ -104,12 +156,16 @@ frontend/src/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
- **Backend**: Jest with ts-jest transformer
|
||||||
|
- **Frontend**: Vitest with React Testing Library patterns
|
||||||
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
|
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
|
||||||
- Integration tests for API endpoints
|
- Integration tests for API endpoints
|
||||||
- E2E tests for critical user flows
|
- E2E tests for critical user flows
|
||||||
- Descriptive test names that explain what is tested
|
- Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`)
|
||||||
- Mock external dependencies (use Jest for backend, Vitest for frontend)
|
- Mock external dependencies (use Jest for backend, Vitest for frontend)
|
||||||
- Run `npm test` for all tests or target specific files/names
|
- Run `npm test` for all tests or target specific files/names
|
||||||
|
- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend)
|
||||||
|
- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend)
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
||||||
@@ -147,5 +203,16 @@ frontend/src/
|
|||||||
- Example backend env: `DATABASE_PATH=./data.db`
|
- Example backend env: `DATABASE_PATH=./data.db`
|
||||||
- Store sensitive config in `.env` files
|
- Store sensitive config in `.env` files
|
||||||
- Use `DATABASE_PATH` for SQLite location
|
- Use `DATABASE_PATH` for SQLite location
|
||||||
|
- Use `@nestjs/config` for backend environment management
|
||||||
|
- Frontend env vars must be prefixed with `VITE_`
|
||||||
|
|
||||||
|
## Agent-Specific Instructions
|
||||||
|
- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes
|
||||||
|
- **NEVER** commit changes without explicit user request
|
||||||
|
- **ALWAYS** follow existing code patterns and conventions
|
||||||
|
- **PREFER** editing existing files over creating new ones
|
||||||
|
- **VERIFY** tests pass before considering work complete
|
||||||
|
- **USE** the root-level commands for common operations
|
||||||
|
- **CHECK** both backend and frontend when making API changes
|
||||||
|
|
||||||
|
|
||||||
+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,70 @@
|
|||||||
|
# Quick Strapi Webhook Setup
|
||||||
|
|
||||||
|
## TL;DR - Quick Configuration
|
||||||
|
|
||||||
|
1. **Access Strapi Admin**: `http://localhost:1337/admin`
|
||||||
|
2. **Go to Settings → Webhooks**
|
||||||
|
3. **Add New Webhook**:
|
||||||
|
- **Name**: `Backend Sync`
|
||||||
|
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||||
|
- **Events**: Select all for "Article" and "Live Blog" content types
|
||||||
|
4. **Save and Test**
|
||||||
|
|
||||||
|
## Already Configured (What We Fixed)
|
||||||
|
|
||||||
|
✅ **Backend webhook endpoints** are now public (no auth required)
|
||||||
|
✅ **Tested webhooks** manually - they work
|
||||||
|
✅ **Articles sync** from Strapi to backend
|
||||||
|
✅ **Frontend TypeScript errors** fixed
|
||||||
|
✅ **Authentication system** working
|
||||||
|
|
||||||
|
## Manual Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test webhook manually
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"event": "entry.publish",
|
||||||
|
"model": "article",
|
||||||
|
"entry": {"documentId": "r07qatlpgvx82d7337n3nz1l"}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Check synced articles
|
||||||
|
curl http://localhost:3000/api/v1/articles?status=published
|
||||||
|
|
||||||
|
# Run comprehensive test
|
||||||
|
./scripts/test-webhooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- **Strapi Articles**: 2
|
||||||
|
- **Backend Articles**: 2 (synced)
|
||||||
|
- **Webhook Status**: Ready for configuration
|
||||||
|
- **Frontend**: Access at `http://localhost:5173/articles`
|
||||||
|
|
||||||
|
## Immediate Action Required
|
||||||
|
|
||||||
|
1. **Configure webhooks in Strapi admin** (see detailed guide in `STRAPI_WEBHOOKS_SETUP.md`)
|
||||||
|
2. **Test by publishing an article** in Strapi
|
||||||
|
3. **Verify automatic sync** works
|
||||||
|
|
||||||
|
## If Webhooks Don't Work
|
||||||
|
|
||||||
|
Use manual sync as fallback:
|
||||||
|
```bash
|
||||||
|
# Get auth token first
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"testuser","password":"Test123!"}' | jq -r '.access_token')
|
||||||
|
|
||||||
|
# Sync all articles
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Visit `http://localhost:5173/articles` to see synced articles
|
||||||
|
- Check backend logs: `docker logs placebo-backend-dev --tail 20`
|
||||||
|
- Monitor sync status with test script: `./scripts/test-webhooks.sh`
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Strapi Webhooks Configuration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This guide explains how to configure Strapi webhooks for automatic synchronization with the backend API. When articles or live blogs are published/updated/deleted in Strapi, webhooks will automatically trigger synchronization with the backend.
|
||||||
|
|
||||||
|
## Webhook Endpoints
|
||||||
|
|
||||||
|
The backend provides three webhook endpoints:
|
||||||
|
|
||||||
|
1. **Article-specific webhook**: `POST /api/v1/webhooks/strapi/article`
|
||||||
|
2. **Live Blog-specific webhook**: `POST /api/v1/webhooks/strapi/live-blog`
|
||||||
|
3. **Generic webhook**: `POST /api/v1/webhooks/strapi` (handles both)
|
||||||
|
|
||||||
|
All endpoints accept the following JSON format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "entry.publish", // or "entry.update", "entry.delete", "entry.unpublish"
|
||||||
|
"model": "article", // or "live-blog"
|
||||||
|
"entry": {
|
||||||
|
"documentId": "unique-strapi-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Steps
|
||||||
|
|
||||||
|
### Step 1: Access Strapi Admin Panel
|
||||||
|
1. Open your browser and go to: `http://localhost:1337/admin`
|
||||||
|
2. Log in with your admin credentials
|
||||||
|
|
||||||
|
### Step 2: Configure Webhooks
|
||||||
|
1. In the left sidebar, click on **Settings** (gear icon)
|
||||||
|
2. Click on **Webhooks** under the "GLOBAL SETTINGS" section
|
||||||
|
3. Click the **"Add new webhook"** button
|
||||||
|
|
||||||
|
### Step 3: Configure Article Webhook
|
||||||
|
**Basic Settings:**
|
||||||
|
- **Name**: `Backend Article Sync`
|
||||||
|
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/article`
|
||||||
|
- For Docker internal communication: `http://backend:3000/api/v1/webhooks/strapi/article`
|
||||||
|
- **Headers**: Add if needed (usually not required)
|
||||||
|
|
||||||
|
**Trigger Events:**
|
||||||
|
Select the following events for the "Article" content type:
|
||||||
|
- [x] **Create entry** (`entry.create`)
|
||||||
|
- [x] **Update entry** (`entry.update`)
|
||||||
|
- [x] **Delete entry** (`entry.delete`)
|
||||||
|
- [x] **Publish entry** (`entry.publish`)
|
||||||
|
- [x] **Unpublish entry** (`entry.unpublish`)
|
||||||
|
|
||||||
|
**Save the webhook.**
|
||||||
|
|
||||||
|
### Step 4: Configure Live Blog Webhook (Optional)
|
||||||
|
Repeat Step 3 for live blogs:
|
||||||
|
- **Name**: `Backend Live Blog Sync`
|
||||||
|
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/live-blog`
|
||||||
|
- Select events for the "Live Blog" content type
|
||||||
|
|
||||||
|
### Alternative: Single Generic Webhook
|
||||||
|
You can use a single webhook for all content types:
|
||||||
|
- **Name**: `Backend Generic Sync`
|
||||||
|
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||||
|
- Select events for ALL relevant content types (Article, Live Blog)
|
||||||
|
|
||||||
|
## Testing Webhooks
|
||||||
|
|
||||||
|
### Manual Test
|
||||||
|
You can manually trigger a webhook to test the configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test article webhook
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/article \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"event": "entry.publish",
|
||||||
|
"model": "article",
|
||||||
|
"entry": {
|
||||||
|
"documentId": "your-article-id"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Test generic webhook
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"event": "entry.publish",
|
||||||
|
"model": "article",
|
||||||
|
"entry": {
|
||||||
|
"documentId": "your-article-id"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test from Strapi
|
||||||
|
1. In Strapi admin, edit any article
|
||||||
|
2. Click "Save" or "Publish"
|
||||||
|
3. Check backend logs for webhook receipt:
|
||||||
|
```bash
|
||||||
|
docker logs placebo-backend-dev --tail 20 | grep -i "webhook\|strapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Sync (Fallback)
|
||||||
|
If webhooks fail or for initial sync, use manual sync endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get authentication token first
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"your-username","password":"your-password"}'
|
||||||
|
|
||||||
|
# Sync all articles (requires auth)
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||||
|
|
||||||
|
# Sync all live blogs
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/live-blogs \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||||
|
|
||||||
|
# Sync everything
|
||||||
|
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/everything \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Webhook not received by backend**
|
||||||
|
- Check backend logs: `docker logs placebo-backend-dev --tail 50`
|
||||||
|
- Verify CORS is configured (should allow `http://localhost:1337`)
|
||||||
|
- Test webhook manually with curl
|
||||||
|
|
||||||
|
2. **401 Unauthorized error**
|
||||||
|
- Webhook endpoints are now public (no auth required)
|
||||||
|
- If getting 401, check that `@Public()` decorator is on webhook methods
|
||||||
|
|
||||||
|
3. **Webhook received but sync fails**
|
||||||
|
- Check Strapi API token in backend `.env.docker`
|
||||||
|
- Verify Strapi is accessible from backend container
|
||||||
|
- Check backend logs for specific error messages
|
||||||
|
|
||||||
|
4. **Article not appearing in frontend**
|
||||||
|
- Verify article is synced to backend: `curl http://localhost:3000/api/v1/articles`
|
||||||
|
- Check frontend browser console for errors
|
||||||
|
- Hard refresh frontend (Ctrl+F5)
|
||||||
|
|
||||||
|
### Log Monitoring
|
||||||
|
```bash
|
||||||
|
# Monitor backend logs for webhooks
|
||||||
|
docker logs -f placebo-backend-dev | grep -i "webhook\|strapi"
|
||||||
|
|
||||||
|
# Monitor Strapi logs
|
||||||
|
docker logs -f placebo-cms-dev
|
||||||
|
|
||||||
|
# Check if articles are in backend
|
||||||
|
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Network Considerations
|
||||||
|
|
||||||
|
### Internal Docker Communication
|
||||||
|
- Within Docker network: Use `http://backend:3000` and `http://cms:1337`
|
||||||
|
- From host machine: Use `http://localhost:3000` and `http://localhost:1337`
|
||||||
|
|
||||||
|
### Webhook URL Examples
|
||||||
|
- **From Strapi container to Backend**: `http://backend:3000/api/v1/webhooks/strapi`
|
||||||
|
- **From Host to Backend**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||||
|
- **External/Production**: Use your domain: `https://api.yourdomain.com/api/v1/webhooks/strapi`
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Webhook endpoints are public** - ensure your backend is not exposed to the public internet in production
|
||||||
|
2. **Consider adding webhook signature verification** for production
|
||||||
|
3. **Use HTTPS in production** for all webhook calls
|
||||||
|
4. **Monitor webhook logs** for suspicious activity
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] Webhooks configured in Strapi admin
|
||||||
|
- [ ] Test webhook manually works
|
||||||
|
- [ ] Article publish in Strapi triggers sync
|
||||||
|
- [ ] Synced articles appear in backend API
|
||||||
|
- [ ] Frontend displays synced articles
|
||||||
|
- [ ] All event types tested (publish, update, delete)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
If issues persist:
|
||||||
|
1. Check all service logs
|
||||||
|
2. Verify network connectivity between containers
|
||||||
|
3. Test each component independently
|
||||||
|
4. Review error messages in logs
|
||||||
+31
-4
@@ -1,7 +1,34 @@
|
|||||||
PORT=3000
|
# Environment Configuration Template
|
||||||
DATABASE_PATH=./database.sqlite
|
# Copy to .env and adjust values for your setup
|
||||||
NODE_ENV=development
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
|
|
||||||
|
# ===== 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
|
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
|
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,31 @@
|
|||||||
|
# Backend Development Dockerfile for Placebo.mk NestJS API
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# 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
+797
-342
File diff suppressed because it is too large.
Load diff
+16
-1
@@ -19,17 +19,32 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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",
|
||||||
|
"seed:admin": "ts-node scripts/seed-admin.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
async function resetDatabase() {
|
||||||
|
console.log('Resetting database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drop and recreate the database
|
||||||
|
const { stdout, stderr } = await execAsync(`
|
||||||
|
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "DROP DATABASE IF EXISTS placebo_backend_db;"
|
||||||
|
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "CREATE DATABASE placebo_backend_db;"
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (stderr && !stderr.includes('warning')) {
|
||||||
|
console.error('Error:', stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database reset successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset database:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDatabase();
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Resetting PostgreSQL database..."
|
||||||
|
|
||||||
|
# Connect to PostgreSQL and reset the database (without -t for TTY)
|
||||||
|
docker exec -i placebo-postgres-dev psql -U placebo_user -d postgres <<EOF
|
||||||
|
DROP DATABASE IF EXISTS placebo_backend_db;
|
||||||
|
CREATE DATABASE placebo_backend_db;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Database reset complete!"
|
||||||
|
echo "The new schema will be created when the backend starts with DATABASE_SYNCHRONIZE=true"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { UserService } from '../src/modules/users/user.service';
|
||||||
|
import { UserRole } from '../src/modules/entities';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
|
const userService = app.get(UserService);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if admin user already exists
|
||||||
|
const existingAdmin = await userService.findByUsername('admin');
|
||||||
|
if (existingAdmin) {
|
||||||
|
console.log('Admin user already exists');
|
||||||
|
await app.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const adminUser = await userService.create({
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@placebo.mk',
|
||||||
|
password: 'admin123', // Change this in production!
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin user created successfully:');
|
||||||
|
console.log(`Username: ${adminUser.username}`);
|
||||||
|
console.log(`Email: ${adminUser.email}`);
|
||||||
|
console.log(`Role: ${adminUser.role}`);
|
||||||
|
console.log('\n⚠️ IMPORTANT: Change the default password immediately!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating admin user:', error);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -5,7 +5,20 @@ import { AppController } from './app.controller';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArticlesModule } from './modules/articles.module';
|
import { ArticlesModule } from './modules/articles.module';
|
||||||
import { StrapiModule } from './modules/strapi.module';
|
import { StrapiModule } from './modules/strapi.module';
|
||||||
import { Article, Author, Category } from './modules/entities';
|
import { LiveBlogModule } from './modules/live-blog.module';
|
||||||
|
import { UserModule } from './modules/users/user.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { CommentModule } from './modules/comment/comment.module';
|
||||||
|
import {
|
||||||
|
Article,
|
||||||
|
Author,
|
||||||
|
Category,
|
||||||
|
LiveBlog,
|
||||||
|
LiveBlogUpdate,
|
||||||
|
User,
|
||||||
|
Comment,
|
||||||
|
Reaction,
|
||||||
|
} from './modules/entities';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -13,14 +26,31 @@ import { Article, Author, Category } from './modules/entities';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
type: 'sqlite',
|
type: 'postgres',
|
||||||
database: process.env.DATABASE_PATH ?? './database.sqlite',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
entities: [Article, Author, Category],
|
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||||
synchronize: process.env.NODE_ENV !== 'production',
|
username: process.env.DATABASE_USERNAME || 'placebo_user',
|
||||||
logging: process.env.NODE_ENV === 'development',
|
password: process.env.DATABASE_PASSWORD || 'placebo_password',
|
||||||
|
database: process.env.DATABASE_NAME || 'placebo_backend_db',
|
||||||
|
entities: [
|
||||||
|
Article,
|
||||||
|
Author,
|
||||||
|
Category,
|
||||||
|
LiveBlog,
|
||||||
|
LiveBlogUpdate,
|
||||||
|
User,
|
||||||
|
Comment,
|
||||||
|
Reaction,
|
||||||
|
],
|
||||||
|
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
|
||||||
|
logging: process.env.DATABASE_LOGGING === 'true',
|
||||||
}),
|
}),
|
||||||
ArticlesModule,
|
ArticlesModule,
|
||||||
StrapiModule,
|
StrapiModule,
|
||||||
|
LiveBlogModule,
|
||||||
|
UserModule,
|
||||||
|
AuthModule,
|
||||||
|
CommentModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
+22
-2
@@ -1,5 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory, Reflector } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -15,6 +17,24 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
|
// Apply global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply global authentication guard
|
||||||
|
const reflector = app.get(Reflector);
|
||||||
|
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
|
||||||
|
|
||||||
|
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();
|
void bootstrap();
|
||||||
@@ -4,10 +4,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
ValidationPipe,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import {
|
import {
|
||||||
@@ -16,42 +17,66 @@ import {
|
|||||||
FindArticlesDto,
|
FindArticlesDto,
|
||||||
} from './articles.dto';
|
} from './articles.dto';
|
||||||
|
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './auth/roles.guard';
|
||||||
|
import { Roles } from './auth/roles.decorator';
|
||||||
|
import { Public } from './auth/public.decorator';
|
||||||
|
import { UserRole } from './entities';
|
||||||
|
|
||||||
@Controller('articles')
|
@Controller('articles')
|
||||||
export class ArticlesController {
|
export class ArticlesController {
|
||||||
constructor(private readonly articlesService: ArticlesService) {}
|
constructor(private readonly articlesService: ArticlesService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) {
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
create(@Body() dto: CreateArticleDto) {
|
||||||
return this.articlesService.create(dto);
|
return this.articlesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
@Public()
|
||||||
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
|
findAll(@Query() dto: FindArticlesDto) {
|
||||||
) {
|
|
||||||
return this.articlesService.findAll(dto);
|
return this.articlesService.findAll(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.articlesService.findOne(id);
|
return this.articlesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('slug/:slug')
|
@Get('slug/:slug')
|
||||||
|
@Public()
|
||||||
findBySlug(@Param('slug') slug: string) {
|
findBySlug(@Param('slug') slug: string) {
|
||||||
return this.articlesService.findBySlug(slug);
|
return this.articlesService.findBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Param('id') id: string,
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
|
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
|
||||||
) {
|
|
||||||
return this.articlesService.update(id, dto);
|
return this.articlesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.articlesService.remove(id);
|
return this.articlesService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/archive')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
archive(@Param('id') id: string) {
|
||||||
|
return this.articlesService.archive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/publish')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
publish(@Param('id') id: string) {
|
||||||
|
return this.articlesService.publish(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,16 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
|
IsBoolean,
|
||||||
|
IsDate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ArticleStatus } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
export class CreateArticleDto {
|
export class CreateArticleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -47,6 +55,26 @@ export class CreateArticleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateArticleDto {
|
export class UpdateArticleDto {
|
||||||
@@ -90,6 +118,26 @@ export class UpdateArticleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FindArticlesDto {
|
export class FindArticlesDto {
|
||||||
@@ -106,8 +154,194 @@ export class FindArticlesDto {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ArticleStatus)
|
@IsString()
|
||||||
status?: ArticleStatus;
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateLiveBlogDto {
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LiveBlogStatus)
|
||||||
|
status?: LiveBlogStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateLiveBlogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LiveBlogStatus)
|
||||||
|
status?: LiveBlogStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateLiveBlogUpdateDto {
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDate()
|
||||||
|
scheduledAt?: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateLiveBlogUpdateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDate()
|
||||||
|
scheduledAt?: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindLiveBlogsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -118,6 +352,5 @@ export class FindArticlesDto {
|
|||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import { ArticlesController } from './articles.controller';
|
import { ArticlesController } from './articles.controller';
|
||||||
import { Article, Author, Category } from './entities';
|
import { Article, Author, Category } from './entities';
|
||||||
|
import { StrapiModule } from './strapi.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Article, Author, Category]),
|
||||||
|
forwardRef(() => StrapiModule),
|
||||||
|
],
|
||||||
controllers: [ArticlesController],
|
controllers: [ArticlesController],
|
||||||
providers: [ArticlesService],
|
providers: [ArticlesService],
|
||||||
exports: [ArticlesService],
|
exports: [ArticlesService],
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Article, ArticleStatus } from './entities';
|
import { Article, ArticleStatus } from './entities';
|
||||||
|
import { StrapiService } from './strapi.service';
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
@@ -10,9 +17,13 @@ import {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ArticlesService {
|
export class ArticlesService {
|
||||||
|
private readonly logger = new Logger(ArticlesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Article)
|
@InjectRepository(Article)
|
||||||
private readonly articleRepository: Repository<Article>,
|
private readonly articleRepository: Repository<Article>,
|
||||||
|
@Inject(forwardRef(() => StrapiService))
|
||||||
|
private readonly strapiService: StrapiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateArticleDto): Promise<Article> {
|
async create(dto: CreateArticleDto): Promise<Article> {
|
||||||
@@ -26,21 +37,23 @@ export class ArticlesService {
|
|||||||
async findAll(
|
async findAll(
|
||||||
dto: FindArticlesDto,
|
dto: FindArticlesDto,
|
||||||
): Promise<{ data: Article[]; total: number }> {
|
): Promise<{ data: Article[]; total: number }> {
|
||||||
const {
|
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
|
||||||
category,
|
|
||||||
author,
|
|
||||||
tag,
|
|
||||||
status = ArticleStatus.PUBLISHED,
|
|
||||||
search,
|
|
||||||
page = 1,
|
|
||||||
limit = 10,
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
const queryBuilder = this.articleRepository
|
const queryBuilder = this.articleRepository
|
||||||
.createQueryBuilder('article')
|
.createQueryBuilder('article')
|
||||||
.leftJoinAndSelect('article.author', 'author')
|
.leftJoinAndSelect('article.author', 'author')
|
||||||
.leftJoinAndSelect('article.category', 'category')
|
.leftJoinAndSelect('article.category', 'category');
|
||||||
.where('article.status = :status', { status });
|
|
||||||
|
// Handle status filter - can be single value or comma-separated list
|
||||||
|
if (status) {
|
||||||
|
if (typeof status === 'string' && status.includes(',')) {
|
||||||
|
const statuses = status.split(',').map((s) => s.trim());
|
||||||
|
queryBuilder.where('article.status IN (:...statuses)', { statuses });
|
||||||
|
} else {
|
||||||
|
queryBuilder.where('article.status = :status', { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no status specified, return all articles (for admin dashboard)
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
queryBuilder.andWhere('category.slug = :category', { category });
|
queryBuilder.andWhere('category.slug = :category', { category });
|
||||||
@@ -98,31 +111,186 @@ export class ArticlesService {
|
|||||||
|
|
||||||
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||||
const article = await this.findOne(id);
|
const article = await this.findOne(id);
|
||||||
|
const oldStatus = article.status;
|
||||||
Object.assign(article, dto);
|
Object.assign(article, dto);
|
||||||
return await this.articleRepository.save(article);
|
const savedArticle = await this.articleRepository.save(article);
|
||||||
|
|
||||||
|
// Update Strapi if status changed and article has strapiId
|
||||||
|
if (
|
||||||
|
dto.status !== undefined &&
|
||||||
|
dto.status !== oldStatus &&
|
||||||
|
article.strapiId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateArticleStatusInStrapi(
|
||||||
|
article.strapiId,
|
||||||
|
dto.status,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for article ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedArticle;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const article = await this.findOne(id);
|
const article = await this.findOne(id);
|
||||||
|
|
||||||
|
// Delete from Strapi if article has strapiId
|
||||||
|
if (article.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - we still want to delete from backend
|
||||||
|
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.articleRepository.remove(article);
|
await this.articleRepository.remove(article);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archive(id: string): Promise<Article> {
|
||||||
|
const article = await this.findOne(id);
|
||||||
|
article.status = ArticleStatus.ARCHIVED;
|
||||||
|
const savedArticle = await this.articleRepository.save(article);
|
||||||
|
|
||||||
|
// Update Strapi if article has strapiId
|
||||||
|
if (article.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateArticleStatusInStrapi(
|
||||||
|
article.strapiId,
|
||||||
|
ArticleStatus.ARCHIVED,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for article ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedArticle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(
|
||||||
|
id: string,
|
||||||
|
status: ArticleStatus = ArticleStatus.PUBLISHED,
|
||||||
|
): Promise<Article> {
|
||||||
|
const article = await this.findOne(id);
|
||||||
|
article.status = status;
|
||||||
|
const savedArticle = await this.articleRepository.save(article);
|
||||||
|
|
||||||
|
// Update Strapi if article has strapiId
|
||||||
|
if (article.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateArticleStatusInStrapi(
|
||||||
|
article.strapiId,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for article ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedArticle;
|
||||||
|
}
|
||||||
|
|
||||||
async syncFromStrapi(
|
async syncFromStrapi(
|
||||||
strapiId: string,
|
strapiId: string,
|
||||||
data: Partial<CreateArticleDto>,
|
data: Partial<CreateArticleDto>,
|
||||||
): Promise<Article> {
|
): Promise<Article> {
|
||||||
let article = await this.articleRepository.findOne({ where: { strapiId } });
|
// Use upsert to handle race conditions and ensure uniqueness
|
||||||
|
try {
|
||||||
|
// First try to find existing article by strapiId
|
||||||
|
const article = await this.articleRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (article) {
|
||||||
|
// Update existing article
|
||||||
|
const currentStatus = article.status;
|
||||||
|
Object.assign(article, data);
|
||||||
|
|
||||||
|
// Preserve archived status if article is already archived in our system
|
||||||
|
// unless Strapi explicitly sends a different status
|
||||||
|
if (
|
||||||
|
currentStatus === ArticleStatus.ARCHIVED &&
|
||||||
|
data.status !== ArticleStatus.ARCHIVED
|
||||||
|
) {
|
||||||
|
article.status = ArticleStatus.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.articleRepository.save(article);
|
||||||
|
} else {
|
||||||
|
// Create new article
|
||||||
|
const newArticle = this.articleRepository.create({
|
||||||
|
strapiId,
|
||||||
|
...data,
|
||||||
|
status: data.status || ArticleStatus.DRAFT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.articleRepository.save(newArticle);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// If we get a unique constraint violation, try to find and update again
|
||||||
|
// This handles race conditions where two syncs happen simultaneously
|
||||||
|
const dbError = error as { code?: string; constraint?: string };
|
||||||
|
if (
|
||||||
|
dbError.code === '23505' &&
|
||||||
|
dbError.constraint?.includes('strapiId')
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait a bit and retry
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const existingArticle = await this.articleRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingArticle) {
|
||||||
|
const currentStatus = existingArticle.status;
|
||||||
|
Object.assign(existingArticle, data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentStatus === ArticleStatus.ARCHIVED &&
|
||||||
|
data.status !== ArticleStatus.ARCHIVED
|
||||||
|
) {
|
||||||
|
existingArticle.status = ArticleStatus.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.articleRepository.save(existingArticle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||||
|
const article = await this.articleRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
article = this.articleRepository.create({
|
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||||
strapiId,
|
return;
|
||||||
...data,
|
|
||||||
status: data.status || ArticleStatus.DRAFT,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(article, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.articleRepository.save(article);
|
await this.articleRepository.remove(article);
|
||||||
|
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginUserDto, CreateUserDto } from '../users/user.dto';
|
||||||
|
import { LocalAuthGuard } from './local-auth.guard';
|
||||||
|
import { Public } from './public.decorator';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(LocalAuthGuard)
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() loginUserDto: LoginUserDto) {
|
||||||
|
return this.authService.login(loginUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
async register(@Body() createUserDto: CreateUserDto) {
|
||||||
|
return this.authService.register(createUserDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { UserModule } from '../users/user.module';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { LocalStrategy } from './local.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UserModule,
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const expiration = configService.get<string>('JWT_EXPIRATION') || '7d';
|
||||||
|
return {
|
||||||
|
secret:
|
||||||
|
configService.get<string>('JWT_SECRET') || 'default-secret-key',
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: expiration ? parseInt(expiration) : 3600, // Convert to number
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { UserService } from '../users/user.service';
|
||||||
|
import { CreateUserDto, LoginUserDto } from '../users/user.dto';
|
||||||
|
import { AuthResponse, JwtPayload } from './types';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private userService: UserService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
} | null> {
|
||||||
|
return this.userService.validateUser(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(loginUserDto: LoginUserDto): Promise<AuthResponse> {
|
||||||
|
const user = await this.validateUser(
|
||||||
|
loginUserDto.username,
|
||||||
|
loginUserDto.password,
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role as UserRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: this.jwtService.sign(payload),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role as UserRole,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(createUserDto: CreateUserDto): Promise<AuthResponse> {
|
||||||
|
// Default new users to USER role
|
||||||
|
const userDto = {
|
||||||
|
...createUserDto,
|
||||||
|
role: UserRole.USER,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await this.userService.create(userDto);
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: this.jwtService.sign(payload),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthPublicGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtPayload } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey:
|
||||||
|
configService.get<string>('JWT_SECRET') || 'default-secret-key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
await Promise.resolve(); // Add await to satisfy eslint rule
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
username: payload.username,
|
||||||
|
email: payload.email,
|
||||||
|
role: payload.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Strategy } from 'passport-local';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private authService: AuthService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ id: string; username: string; email: string; role: string }> {
|
||||||
|
const user = await this.authService.validateUser(username, password);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||||
|
'roles',
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
const user = request.user as
|
||||||
|
| { id: string; username: string; email: string; role: UserRole }
|
||||||
|
| undefined;
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiredRoles.includes(user.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestWithUser extends Request {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import {
|
||||||
|
CreateCommentDto,
|
||||||
|
UpdateCommentDto,
|
||||||
|
CommentResponseDto,
|
||||||
|
FindCommentsDto,
|
||||||
|
CreateReactionDto,
|
||||||
|
} from './comment.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
import type { RequestWithUser } from '../auth/types';
|
||||||
|
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentController {
|
||||||
|
constructor(private readonly commentService: CommentService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async create(
|
||||||
|
@Body() createCommentDto: CreateCommentDto,
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
): Promise<CommentResponseDto> {
|
||||||
|
return this.commentService.create(createCommentDto, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
async findAll(
|
||||||
|
@Query() findCommentsDto: FindCommentsDto,
|
||||||
|
): Promise<CommentResponseDto[]> {
|
||||||
|
return this.commentService.findAll(findCommentsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
|
async findOne(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||||
|
return this.commentService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updateCommentDto: UpdateCommentDto,
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
): Promise<CommentResponseDto> {
|
||||||
|
return this.commentService.update(
|
||||||
|
id,
|
||||||
|
updateCommentDto,
|
||||||
|
req.user.id,
|
||||||
|
req.user.role,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.commentService.remove(id, req.user.id, req.user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reactions')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async addReaction(
|
||||||
|
@Body() createReactionDto: CreateReactionDto,
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.commentService.addReaction(createReactionDto, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reactions/user')
|
||||||
|
@Public()
|
||||||
|
async getUserReaction(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Query('articleId') articleId?: string,
|
||||||
|
@Query('liveBlogId') liveBlogId?: string,
|
||||||
|
@Query('commentId') commentId?: string,
|
||||||
|
): Promise<{ type: string | null }> {
|
||||||
|
// If user is not authenticated, return null
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reaction = await this.commentService.getUserReaction(
|
||||||
|
req.user.id,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
return { type: reaction };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reactions/counts')
|
||||||
|
@Public()
|
||||||
|
async getReactionCounts(
|
||||||
|
@Query('articleId') articleId?: string,
|
||||||
|
@Query('liveBlogId') liveBlogId?: string,
|
||||||
|
@Query('commentId') commentId?: string,
|
||||||
|
): Promise<{ likes: number; dislikes: number }> {
|
||||||
|
return this.commentService.getReactionCounts(
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
@Patch(':id/hide')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async hideComment(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||||
|
return this.commentService.update(
|
||||||
|
id,
|
||||||
|
{ isVisible: false },
|
||||||
|
'admin',
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/show')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async showComment(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||||
|
return this.commentService.update(
|
||||||
|
id,
|
||||||
|
{ isVisible: true },
|
||||||
|
'admin',
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
interface CommentEntity {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
userId: string;
|
||||||
|
likeCount: number;
|
||||||
|
dislikeCount: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
replies?: CommentEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
articleId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
liveBlogId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
@IsOptional()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommentResponseDto {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
userId: string;
|
||||||
|
likeCount: number;
|
||||||
|
dislikeCount: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
replies?: CommentResponseDto[];
|
||||||
|
|
||||||
|
constructor(comment: CommentEntity) {
|
||||||
|
this.id = comment.id;
|
||||||
|
this.content = comment.content;
|
||||||
|
this.articleId = comment.articleId;
|
||||||
|
this.liveBlogId = comment.liveBlogId;
|
||||||
|
this.parentId = comment.parentId;
|
||||||
|
this.userId = comment.userId;
|
||||||
|
this.likeCount = comment.likeCount;
|
||||||
|
this.dislikeCount = comment.dislikeCount;
|
||||||
|
this.isVisible = comment.isVisible;
|
||||||
|
this.createdAt = comment.createdAt;
|
||||||
|
this.updatedAt = comment.updatedAt;
|
||||||
|
|
||||||
|
if (comment.user) {
|
||||||
|
this.user = {
|
||||||
|
id: comment.user.id,
|
||||||
|
username: comment.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.replies) {
|
||||||
|
this.replies = comment.replies.map(
|
||||||
|
(reply) => new CommentResponseDto(reply),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindCommentsDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
articleId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
liveBlogId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateReactionDto {
|
||||||
|
@IsString()
|
||||||
|
type: 'like' | 'dislike';
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
articleId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
liveBlogId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
commentId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Comment, Reaction } from '../entities';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { CommentController } from './comment.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Comment, Reaction])],
|
||||||
|
controllers: [CommentController],
|
||||||
|
providers: [CommentService],
|
||||||
|
exports: [CommentService],
|
||||||
|
})
|
||||||
|
export class CommentModule {}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Comment, Reaction, ReactionType } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateCommentDto,
|
||||||
|
UpdateCommentDto,
|
||||||
|
CommentResponseDto,
|
||||||
|
FindCommentsDto,
|
||||||
|
CreateReactionDto,
|
||||||
|
} from './comment.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Comment)
|
||||||
|
private commentRepository: Repository<Comment>,
|
||||||
|
@InjectRepository(Reaction)
|
||||||
|
private reactionRepository: Repository<Reaction>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
createCommentDto: CreateCommentDto,
|
||||||
|
userId: string,
|
||||||
|
): Promise<CommentResponseDto> {
|
||||||
|
// Validate that comment is attached to either article or live blog
|
||||||
|
if (!createCommentDto.articleId && !createCommentDto.liveBlogId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Comment must be attached to either an article or live blog',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parentId is provided, verify it exists
|
||||||
|
if (createCommentDto.parentId) {
|
||||||
|
const parent = await this.commentRepository.findOne({
|
||||||
|
where: { id: createCommentDto.parentId },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException('Parent comment not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = this.commentRepository.create({
|
||||||
|
...createCommentDto,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedComment = await this.commentRepository.save(comment);
|
||||||
|
return this.findOne(savedComment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
findCommentsDto: FindCommentsDto,
|
||||||
|
): Promise<CommentResponseDto[]> {
|
||||||
|
const {
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
parentId,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
} = findCommentsDto;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const query = this.commentRepository
|
||||||
|
.createQueryBuilder('comment')
|
||||||
|
.leftJoinAndSelect('comment.user', 'user')
|
||||||
|
.leftJoinAndSelect('comment.replies', 'replies')
|
||||||
|
.leftJoinAndSelect('replies.user', 'replyUser')
|
||||||
|
.where('comment.isVisible = :isVisible', { isVisible: true });
|
||||||
|
|
||||||
|
if (articleId) {
|
||||||
|
query.andWhere('comment.articleId = :articleId', { articleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveBlogId) {
|
||||||
|
query.andWhere('comment.liveBlogId = :liveBlogId', { liveBlogId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
query.andWhere('comment.parentId = :parentId', { parentId });
|
||||||
|
} else {
|
||||||
|
query.andWhere('comment.parentId IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('comment.createdAt', 'DESC').skip(skip).take(limit);
|
||||||
|
|
||||||
|
const comments = await query.getMany();
|
||||||
|
return comments.map((comment) => new CommentResponseDto(comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<CommentResponseDto> {
|
||||||
|
const comment = await this.commentRepository
|
||||||
|
.createQueryBuilder('comment')
|
||||||
|
.leftJoinAndSelect('comment.user', 'user')
|
||||||
|
.leftJoinAndSelect('comment.replies', 'replies')
|
||||||
|
.leftJoinAndSelect('replies.user', 'replyUser')
|
||||||
|
.where('comment.id = :id', { id })
|
||||||
|
.andWhere('comment.isVisible = :isVisible', { isVisible: true })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommentResponseDto(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updateCommentDto: UpdateCommentDto,
|
||||||
|
userId: string,
|
||||||
|
userRole: string,
|
||||||
|
): Promise<CommentResponseDto> {
|
||||||
|
const comment = await this.commentRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow comment owner or admin to update
|
||||||
|
if (comment.userId !== userId && userRole !== 'admin') {
|
||||||
|
throw new BadRequestException('You can only update your own comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(comment, updateCommentDto);
|
||||||
|
const updatedComment = await this.commentRepository.save(comment);
|
||||||
|
return this.findOne(updatedComment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, userId: string, userRole: string): Promise<void> {
|
||||||
|
const comment = await this.commentRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow comment owner or admin to delete
|
||||||
|
if (comment.userId !== userId && userRole !== 'admin') {
|
||||||
|
throw new BadRequestException('You can only delete your own comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentRepository.remove(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReaction(
|
||||||
|
createReactionDto: CreateReactionDto,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate that reaction is attached to either article, live blog, or comment
|
||||||
|
const { articleId, liveBlogId, commentId, type } = createReactionDto;
|
||||||
|
|
||||||
|
if (!articleId && !liveBlogId && !commentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Reaction must be attached to an article, live blog, or comment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already reacted to this item
|
||||||
|
const whereClause: {
|
||||||
|
userId: string;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
} = { userId };
|
||||||
|
if (articleId) whereClause.articleId = articleId;
|
||||||
|
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
|
||||||
|
if (commentId) whereClause.commentId = commentId;
|
||||||
|
|
||||||
|
const existingReaction = await this.reactionRepository.findOne({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReaction) {
|
||||||
|
// If same reaction type, remove it (toggle)
|
||||||
|
if (existingReaction.type === (type as ReactionType)) {
|
||||||
|
await this.reactionRepository.remove(existingReaction);
|
||||||
|
|
||||||
|
// Update comment counts if reacting to comment
|
||||||
|
if (commentId) {
|
||||||
|
await this.updateCommentReactionCount(commentId, type, -1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Change reaction type
|
||||||
|
existingReaction.type = type as ReactionType;
|
||||||
|
await this.reactionRepository.save(existingReaction);
|
||||||
|
|
||||||
|
// Update comment counts if reacting to comment
|
||||||
|
if (commentId) {
|
||||||
|
// Decrement old reaction count
|
||||||
|
await this.updateCommentReactionCount(
|
||||||
|
commentId,
|
||||||
|
existingReaction.type === ReactionType.LIKE
|
||||||
|
? ReactionType.DISLIKE
|
||||||
|
: ReactionType.LIKE,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
// Increment new reaction count
|
||||||
|
await this.updateCommentReactionCount(commentId, type, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new reaction
|
||||||
|
const reaction = this.reactionRepository.create({
|
||||||
|
...createReactionDto,
|
||||||
|
userId,
|
||||||
|
type: type as ReactionType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.reactionRepository.save(reaction);
|
||||||
|
|
||||||
|
// Update comment counts if reacting to comment
|
||||||
|
if (commentId) {
|
||||||
|
await this.updateCommentReactionCount(commentId, type, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCommentReactionCount(
|
||||||
|
commentId: string,
|
||||||
|
type: 'like' | 'dislike',
|
||||||
|
change: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const comment = await this.commentRepository.findOne({
|
||||||
|
where: { id: commentId },
|
||||||
|
});
|
||||||
|
if (!comment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'like') {
|
||||||
|
comment.likeCount = Math.max(0, comment.likeCount + change);
|
||||||
|
} else {
|
||||||
|
comment.dislikeCount = Math.max(0, comment.dislikeCount + change);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserReaction(
|
||||||
|
userId: string,
|
||||||
|
articleId?: string,
|
||||||
|
liveBlogId?: string,
|
||||||
|
commentId?: string,
|
||||||
|
): Promise<ReactionType | null> {
|
||||||
|
const whereClause: {
|
||||||
|
userId: string;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
} = { userId };
|
||||||
|
if (articleId) whereClause.articleId = articleId;
|
||||||
|
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
|
||||||
|
if (commentId) whereClause.commentId = commentId;
|
||||||
|
|
||||||
|
const reaction = await this.reactionRepository.findOne({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reaction ? reaction.type : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReactionCounts(
|
||||||
|
articleId?: string,
|
||||||
|
liveBlogId?: string,
|
||||||
|
commentId?: string,
|
||||||
|
): Promise<{ likes: number; dislikes: number }> {
|
||||||
|
const query = this.reactionRepository.createQueryBuilder('reaction');
|
||||||
|
|
||||||
|
if (articleId) {
|
||||||
|
query.where('reaction.articleId = :articleId', { articleId });
|
||||||
|
} else if (liveBlogId) {
|
||||||
|
query.where('reaction.liveBlogId = :liveBlogId', { liveBlogId });
|
||||||
|
} else if (commentId) {
|
||||||
|
query.where('reaction.commentId = :commentId', { commentId });
|
||||||
|
} else {
|
||||||
|
return { likes: 0, dislikes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactions = await query.getMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
likes: reactions.filter((r) => r.type === ReactionType.LIKE).length,
|
||||||
|
dislikes: reactions.filter((r) => r.type === ReactionType.DISLIKE).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ValueTransformer,
|
ValueTransformer,
|
||||||
|
OneToMany,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
class ArrayTransformer implements ValueTransformer {
|
class ArrayTransformer implements ValueTransformer {
|
||||||
@@ -15,7 +16,7 @@ class ArrayTransformer implements ValueTransformer {
|
|||||||
}
|
}
|
||||||
from(value: string): string[] {
|
from(value: string): string[] {
|
||||||
try {
|
try {
|
||||||
return value ? JSON.parse(value) : [];
|
return value ? (JSON.parse(value) as string[]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,44 @@ export enum ArticleStatus {
|
|||||||
ARCHIVED = 'archived',
|
ARCHIVED = 'archived',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum LiveBlogStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
LIVE = 'live',
|
||||||
|
ENDED = 'ended',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImagePosition {
|
||||||
|
TOP = 'top',
|
||||||
|
LEFT = 'left',
|
||||||
|
RIGHT = 'right',
|
||||||
|
NONE = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImageSize {
|
||||||
|
SMALL = 'small',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
LARGE = 'large',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoPosition {
|
||||||
|
TOP = 'top',
|
||||||
|
INLINE = 'inline',
|
||||||
|
BOTTOM = 'bottom',
|
||||||
|
NONE = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
CONTRIBUTOR = 'contributor',
|
||||||
|
USER = 'user',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReactionType {
|
||||||
|
LIKE = 'like',
|
||||||
|
DISLIKE = 'dislike',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('authors')
|
@Entity('authors')
|
||||||
export class Author {
|
export class Author {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -86,6 +125,36 @@ export class Category {
|
|||||||
parent: Category;
|
parent: Category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
passwordHash: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'user',
|
||||||
|
})
|
||||||
|
role: UserRole;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('articles')
|
@Entity('articles')
|
||||||
export class Article {
|
export class Article {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -106,6 +175,30 @@ export class Article {
|
|||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
featuredImage: string;
|
featuredImage: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'top',
|
||||||
|
})
|
||||||
|
imagePosition: ImagePosition;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'medium',
|
||||||
|
})
|
||||||
|
imageSize: ImageSize;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
videoUrl: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'inline',
|
||||||
|
})
|
||||||
|
videoPosition: VideoPosition;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
videoCaption: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
default: '[]',
|
default: '[]',
|
||||||
@@ -122,7 +215,7 @@ export class Article {
|
|||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
views: number;
|
views: number;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, unique: true })
|
||||||
strapiId: string;
|
strapiId: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
@@ -144,4 +237,230 @@ export class Article {
|
|||||||
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'categoryId' })
|
@JoinColumn({ name: 'categoryId' })
|
||||||
category: Category;
|
category: Category;
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (comment) => comment.article)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@OneToMany(() => Reaction, (reaction) => reaction.article)
|
||||||
|
reactions: Reaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('live_blogs')
|
||||||
|
export class LiveBlog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'draft',
|
||||||
|
})
|
||||||
|
status: LiveBlogStatus;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isPinned: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true, unique: true })
|
||||||
|
strapiId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
authorId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
featuredImage: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'top',
|
||||||
|
})
|
||||||
|
imagePosition: ImagePosition;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'medium',
|
||||||
|
})
|
||||||
|
imageSize: ImageSize;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
videoUrl: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
default: 'inline',
|
||||||
|
})
|
||||||
|
videoPosition: VideoPosition;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
videoCaption: string;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
viewCount: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'authorId' })
|
||||||
|
author: Author;
|
||||||
|
|
||||||
|
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'categoryId' })
|
||||||
|
category: Category;
|
||||||
|
|
||||||
|
@OneToMany(() => LiveBlogUpdate, (update) => update.liveBlog, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
updates: LiveBlogUpdate[];
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (comment) => comment.liveBlog)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
|
||||||
|
reactions: Reaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('comments')
|
||||||
|
export class Comment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
liveBlogId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
parentId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
likeCount: number;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
dislikeCount: number;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
isVisible: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'articleId' })
|
||||||
|
article: Article;
|
||||||
|
|
||||||
|
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'liveBlogId' })
|
||||||
|
liveBlog: LiveBlog;
|
||||||
|
|
||||||
|
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'parentId' })
|
||||||
|
parent: Comment;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (comment) => comment.parent)
|
||||||
|
replies: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('reactions')
|
||||||
|
export class Reaction {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
})
|
||||||
|
type: ReactionType;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
liveBlogId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
commentId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'articleId' })
|
||||||
|
article: Article;
|
||||||
|
|
||||||
|
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'liveBlogId' })
|
||||||
|
liveBlog: LiveBlog;
|
||||||
|
|
||||||
|
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'commentId' })
|
||||||
|
comment: Comment;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
Logger,
|
||||||
|
Headers,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Response as ExpressResponse } from 'express';
|
||||||
|
import { LiveBlogService } from './live-blog.service';
|
||||||
|
import {
|
||||||
|
CreateLiveBlogDto,
|
||||||
|
UpdateLiveBlogDto,
|
||||||
|
FindLiveBlogsDto,
|
||||||
|
CreateLiveBlogUpdateDto,
|
||||||
|
UpdateLiveBlogUpdateDto,
|
||||||
|
} from './articles.dto';
|
||||||
|
import { LiveBlogStatus, UserRole } from './entities';
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './auth/roles.guard';
|
||||||
|
import { Roles } from './auth/roles.decorator';
|
||||||
|
import { Public } from './auth/public.decorator';
|
||||||
|
|
||||||
|
@Controller('live-blogs')
|
||||||
|
export class LiveBlogController {
|
||||||
|
private readonly logger = new Logger(LiveBlogController.name);
|
||||||
|
|
||||||
|
constructor(private readonly liveBlogService: LiveBlogService) {}
|
||||||
|
|
||||||
|
// Live Blog CRUD operations
|
||||||
|
@Get('featured')
|
||||||
|
@Public()
|
||||||
|
getFeatured() {
|
||||||
|
this.logger.log('GET /featured called');
|
||||||
|
return this.liveBlogService.findPinned();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('active')
|
||||||
|
@Public()
|
||||||
|
getActive() {
|
||||||
|
return this.liveBlogService.findActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('recent')
|
||||||
|
@Public()
|
||||||
|
getRecent() {
|
||||||
|
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
create(@Body() dto: CreateLiveBlogDto) {
|
||||||
|
return this.liveBlogService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
findAll(@Query() dto: FindLiveBlogsDto) {
|
||||||
|
return this.liveBlogService.findAll(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.liveBlogService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('slug/:slug')
|
||||||
|
@Public()
|
||||||
|
findBySlug(@Param('slug') slug: string) {
|
||||||
|
return this.liveBlogService.findBySlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
|
||||||
|
return this.liveBlogService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.liveBlogService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Blog Updates CRUD operations
|
||||||
|
@Post(':id/updates')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
createUpdate(
|
||||||
|
@Param('id') liveBlogId: string,
|
||||||
|
@Body() dto: CreateLiveBlogUpdateDto,
|
||||||
|
) {
|
||||||
|
return this.liveBlogService.createUpdate(dto, liveBlogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/updates')
|
||||||
|
@Public()
|
||||||
|
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')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
updateUpdate(
|
||||||
|
@Param('id') liveBlogId: string,
|
||||||
|
@Param('updateId') updateId: string,
|
||||||
|
@Body() dto: UpdateLiveBlogUpdateDto,
|
||||||
|
) {
|
||||||
|
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/updates/:updateId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
removeUpdate(
|
||||||
|
@Param('id') liveBlogId: string,
|
||||||
|
@Param('updateId') updateId: string,
|
||||||
|
) {
|
||||||
|
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/archive')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
archive(@Param('id') id: string) {
|
||||||
|
return this.liveBlogService.archive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/publish')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
|
||||||
|
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE endpoint for real-time updates
|
||||||
|
@Get(':id/stream')
|
||||||
|
@Public()
|
||||||
|
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,19 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { LiveBlogService } from './live-blog.service';
|
||||||
|
import { LiveBlogController } from './live-blog.controller';
|
||||||
|
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
||||||
|
import { StrapiModule } from './strapi.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
forwardRef(() => StrapiModule),
|
||||||
|
],
|
||||||
|
controllers: [LiveBlogController],
|
||||||
|
providers: [LiveBlogService],
|
||||||
|
exports: [LiveBlogService],
|
||||||
|
})
|
||||||
|
export class LiveBlogModule {}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, In } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
|
||||||
|
import { StrapiService } from './strapi.service';
|
||||||
|
import {
|
||||||
|
CreateLiveBlogDto,
|
||||||
|
UpdateLiveBlogDto,
|
||||||
|
FindLiveBlogsDto,
|
||||||
|
CreateLiveBlogUpdateDto,
|
||||||
|
UpdateLiveBlogUpdateDto,
|
||||||
|
} from './articles.dto';
|
||||||
|
|
||||||
|
interface SseClient {
|
||||||
|
id: string;
|
||||||
|
response: Response;
|
||||||
|
blogId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveBlogUpdateEvent {
|
||||||
|
blogId: string;
|
||||||
|
update: LiveBlogUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveBlogStatusChangeEvent {
|
||||||
|
blogId: string;
|
||||||
|
status: LiveBlogStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveBlogPinUpdateEvent {
|
||||||
|
blogId: string;
|
||||||
|
updateId: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LiveBlogService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(LiveBlogService.name);
|
||||||
|
private readonly sseClients = new Map<string, SseClient>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(LiveBlog)
|
||||||
|
private readonly liveBlogRepository: Repository<LiveBlog>,
|
||||||
|
@InjectRepository(LiveBlogUpdate)
|
||||||
|
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => StrapiService))
|
||||||
|
private readonly strapiService: StrapiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.eventEmitter.on('live-blog.update', (data: LiveBlogUpdateEvent) => {
|
||||||
|
this.broadcastToClients(data.blogId, {
|
||||||
|
type: 'update',
|
||||||
|
data: data.update,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.on(
|
||||||
|
'live-blog.status-change',
|
||||||
|
(data: LiveBlogStatusChangeEvent) => {
|
||||||
|
this.broadcastToClients(data.blogId, {
|
||||||
|
type: 'status-change',
|
||||||
|
data: { status: data.status },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.eventEmitter.on(
|
||||||
|
'live-blog.pin-update',
|
||||||
|
(data: LiveBlogPinUpdateEvent) => {
|
||||||
|
this.broadcastToClients(data.blogId, {
|
||||||
|
type: 'pin-update',
|
||||||
|
data: { updateId: data.updateId, isPinned: data.isPinned },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Blog CRUD operations
|
||||||
|
async create(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||||
|
const liveBlog = this.liveBlogRepository.create({
|
||||||
|
...dto,
|
||||||
|
status: dto.status || LiveBlogStatus.DRAFT,
|
||||||
|
});
|
||||||
|
return await this.liveBlogRepository.save(liveBlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
dto: FindLiveBlogsDto,
|
||||||
|
): Promise<{ data: LiveBlog[]; total: number }> {
|
||||||
|
const {
|
||||||
|
category,
|
||||||
|
author,
|
||||||
|
status,
|
||||||
|
isPinned,
|
||||||
|
search,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
} = dto;
|
||||||
|
|
||||||
|
const queryBuilder = this.liveBlogRepository
|
||||||
|
.createQueryBuilder('liveBlog')
|
||||||
|
.leftJoinAndSelect('liveBlog.author', 'author')
|
||||||
|
.leftJoinAndSelect('liveBlog.category', 'category');
|
||||||
|
|
||||||
|
// Handle status filter - can be single value or comma-separated list
|
||||||
|
if (status) {
|
||||||
|
if (typeof status === 'string' && status.includes(',')) {
|
||||||
|
const statuses = status.split(',').map((s) => s.trim());
|
||||||
|
queryBuilder.where('liveBlog.status IN (:...statuses)', { statuses });
|
||||||
|
} else {
|
||||||
|
queryBuilder.where('liveBlog.status = :status', { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no status specified, return all live blogs (for admin dashboard)
|
||||||
|
// Note: Pinned blogs query should still work without status filter
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
queryBuilder.andWhere('category.slug = :category', { category });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author) {
|
||||||
|
queryBuilder.andWhere('author.slug = :author', { author });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(liveBlog.title ILIKE :search OR liveBlog.description ILIKE :search)',
|
||||||
|
{ search: `%${search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPinned !== undefined) {
|
||||||
|
queryBuilder.andWhere('liveBlog.isPinned = :isPinned', { isPinned });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.orderBy('liveBlog.createdAt', 'DESC')
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.take(limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['author', 'category', 'updates'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liveBlog) {
|
||||||
|
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await this.liveBlogRepository.increment({ id }, 'viewCount', 1);
|
||||||
|
|
||||||
|
return liveBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneWithoutIncrement(id: string): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['author', 'category', 'updates'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liveBlog) {
|
||||||
|
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return liveBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySlug(slug: string): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { slug },
|
||||||
|
relations: ['author', 'category', 'updates'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liveBlog) {
|
||||||
|
throw new NotFoundException(`Live blog with slug ${slug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await this.liveBlogRepository.increment({ slug }, 'viewCount', 1);
|
||||||
|
|
||||||
|
return liveBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['author', 'category'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liveBlog) {
|
||||||
|
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track if status changed for event emission
|
||||||
|
const oldStatus = liveBlog.status;
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if (dto.title !== undefined) {
|
||||||
|
liveBlog.title = dto.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.slug !== undefined) {
|
||||||
|
liveBlog.slug = dto.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
liveBlog.description = dto.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
liveBlog.status = dto.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.strapiId !== undefined) {
|
||||||
|
liveBlog.strapiId = dto.strapiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.authorId !== undefined) {
|
||||||
|
liveBlog.authorId = dto.authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
liveBlog.categoryId = dto.categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.isPinned !== undefined) {
|
||||||
|
liveBlog.isPinned = dto.isPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated entity
|
||||||
|
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
|
||||||
|
|
||||||
|
// Emit status change event if status changed
|
||||||
|
if (dto.status !== undefined && dto.status !== oldStatus) {
|
||||||
|
this.eventEmitter.emit('live-blog.status-change', {
|
||||||
|
blogId: id,
|
||||||
|
status: dto.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Strapi if live blog has strapiId
|
||||||
|
if (liveBlog.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||||
|
liveBlog.strapiId,
|
||||||
|
dto.status,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for live blog ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const liveBlog = await this.findOne(id);
|
||||||
|
|
||||||
|
// Delete from Strapi if live blog has strapiId
|
||||||
|
if (liveBlog.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - we still want to delete from backend
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete live blog ${id} from Strapi:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.liveBlogRepository.remove(liveBlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(id: string): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.findOne(id);
|
||||||
|
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||||
|
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||||
|
|
||||||
|
// Update Strapi if live blog has strapiId
|
||||||
|
if (liveBlog.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||||
|
liveBlog.strapiId,
|
||||||
|
LiveBlogStatus.ARCHIVED,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for live blog ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedLiveBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(
|
||||||
|
id: string,
|
||||||
|
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
|
||||||
|
): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.findOne(id);
|
||||||
|
liveBlog.status = status;
|
||||||
|
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||||
|
|
||||||
|
// Update Strapi if live blog has strapiId
|
||||||
|
if (liveBlog.strapiId) {
|
||||||
|
try {
|
||||||
|
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||||
|
liveBlog.strapiId,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail - backend status is updated
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update Strapi status for live blog ${id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedLiveBlog;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Blog Update CRUD operations
|
||||||
|
async createUpdate(
|
||||||
|
dto: CreateLiveBlogUpdateDto,
|
||||||
|
liveBlogId: string,
|
||||||
|
): Promise<LiveBlogUpdate> {
|
||||||
|
const liveBlogEntity = await this.findOne(liveBlogId);
|
||||||
|
|
||||||
|
const update = this.liveBlogUpdateRepository.create({
|
||||||
|
...dto,
|
||||||
|
liveBlog: liveBlogEntity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
|
||||||
|
|
||||||
|
// Emit update event
|
||||||
|
this.eventEmitter.emit('live-blog.update', {
|
||||||
|
blogId: liveBlogId,
|
||||||
|
update: savedUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return savedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUpdates(
|
||||||
|
liveBlogId: string,
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<{ data: LiveBlogUpdate[]; total: number }> {
|
||||||
|
const [data, total] = await this.liveBlogUpdateRepository.findAndCount({
|
||||||
|
where: { liveBlog: { id: liveBlogId } },
|
||||||
|
relations: ['author'],
|
||||||
|
order: {
|
||||||
|
isPinned: 'DESC',
|
||||||
|
createdAt: 'ASC',
|
||||||
|
},
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUpdate(
|
||||||
|
liveBlogId: string,
|
||||||
|
updateId: string,
|
||||||
|
dto: UpdateLiveBlogUpdateDto,
|
||||||
|
): Promise<LiveBlogUpdate> {
|
||||||
|
const update = await this.liveBlogUpdateRepository.findOne({
|
||||||
|
where: { id: updateId, liveBlog: { id: liveBlogId } },
|
||||||
|
relations: ['liveBlog'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!update) {
|
||||||
|
throw new NotFoundException(`Update with ID ${updateId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(update, dto);
|
||||||
|
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
|
||||||
|
|
||||||
|
// Emit pin change event
|
||||||
|
if (dto.isPinned !== undefined) {
|
||||||
|
this.eventEmitter.emit('live-blog.pin-update', {
|
||||||
|
blogId: liveBlogId,
|
||||||
|
updateId,
|
||||||
|
isPinned: dto.isPinned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUpdate(liveBlogId: string, updateId: string): Promise<void> {
|
||||||
|
const update = await this.liveBlogUpdateRepository.findOne({
|
||||||
|
where: { id: updateId, liveBlog: { id: liveBlogId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!update) {
|
||||||
|
throw new NotFoundException(`Update with ID ${updateId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.liveBlogUpdateRepository.remove(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE operations
|
||||||
|
createStream(liveBlogId: string, response: Response): void {
|
||||||
|
// Verify live blog exists and is live
|
||||||
|
this.findOne(liveBlogId).catch(() => {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientId = `${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
response.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store client connection
|
||||||
|
this.sseClients.set(clientId, {
|
||||||
|
id: clientId,
|
||||||
|
response,
|
||||||
|
blogId: liveBlogId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send periodic keep-alive messages to prevent timeout
|
||||||
|
const keepAliveInterval = setInterval(() => {
|
||||||
|
try {
|
||||||
|
response.write(`: keep-alive\n\n`);
|
||||||
|
} catch {
|
||||||
|
// Client disconnected, stop sending keep-alive
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
}, 15000); // Send keep-alive every 15 seconds
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
response.on('close', () => {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
this.sseClients.delete(clientId);
|
||||||
|
this.logger.log(
|
||||||
|
`Client ${clientId} disconnected from live blog ${liveBlogId}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Client ${clientId} connected to live blog ${liveBlogId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastToClients(liveBlogId: string, message: any): void {
|
||||||
|
const clients = Array.from(this.sseClients.values()).filter(
|
||||||
|
(client) => client.blogId === liveBlogId,
|
||||||
|
);
|
||||||
|
|
||||||
|
clients.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.response.write(`data: ${JSON.stringify(message)}\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send message to client ${client.id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.sseClients.delete(client.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Broadcasted message to ${clients.length} clients for live blog ${liveBlogId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strapi sync operations
|
||||||
|
async syncFromStrapi(
|
||||||
|
strapiId: string,
|
||||||
|
data: Partial<CreateLiveBlogDto>,
|
||||||
|
): Promise<LiveBlog> {
|
||||||
|
// Use upsert to handle race conditions and ensure uniqueness
|
||||||
|
try {
|
||||||
|
// First try to find existing live blog by strapiId
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (liveBlog) {
|
||||||
|
// Update existing live blog
|
||||||
|
const currentStatus = liveBlog.status;
|
||||||
|
Object.assign(liveBlog, data);
|
||||||
|
|
||||||
|
// Preserve archived status if live blog is already archived in our system
|
||||||
|
// unless Strapi explicitly sends a different status
|
||||||
|
if (
|
||||||
|
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||||
|
data.status !== LiveBlogStatus.ARCHIVED
|
||||||
|
) {
|
||||||
|
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.liveBlogRepository.save(liveBlog);
|
||||||
|
} else {
|
||||||
|
// Create new live blog
|
||||||
|
const newLiveBlog = this.liveBlogRepository.create({
|
||||||
|
strapiId,
|
||||||
|
...data,
|
||||||
|
status: data.status || LiveBlogStatus.DRAFT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.liveBlogRepository.save(newLiveBlog);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// If we get a unique constraint violation, try to find and update again
|
||||||
|
// This handles race conditions where two syncs happen simultaneously
|
||||||
|
const dbError = error as { code?: string; constraint?: string };
|
||||||
|
if (
|
||||||
|
dbError.code === '23505' &&
|
||||||
|
dbError.constraint?.includes('strapiId')
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait a bit and retry
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const existingLiveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLiveBlog) {
|
||||||
|
const currentStatus = existingLiveBlog.status;
|
||||||
|
Object.assign(existingLiveBlog, data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||||
|
data.status !== LiveBlogStatus.ARCHIVED
|
||||||
|
) {
|
||||||
|
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.liveBlogRepository.save(existingLiveBlog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||||
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liveBlog) {
|
||||||
|
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.liveBlogRepository.remove(liveBlog);
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully deleted live blog with strapiId: ${strapiId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
|
||||||
|
const since = new Date();
|
||||||
|
since.setHours(since.getHours() - hours);
|
||||||
|
|
||||||
|
return await this.liveBlogRepository
|
||||||
|
.createQueryBuilder('liveBlog')
|
||||||
|
.leftJoinAndSelect('liveBlog.author', 'author')
|
||||||
|
.leftJoinAndSelect('liveBlog.updates', 'updates')
|
||||||
|
.where('liveBlog.status = :status', { status: LiveBlogStatus.LIVE })
|
||||||
|
.andWhere('updates.createdAt > :since', { since })
|
||||||
|
.orderBy('updates.createdAt', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPinned(): Promise<LiveBlog[]> {
|
||||||
|
return this.liveBlogRepository.find({
|
||||||
|
where: {
|
||||||
|
isPinned: true,
|
||||||
|
status: In([LiveBlogStatus.LIVE, LiveBlogStatus.ENDED]), // Show both live and ended pinned blogs
|
||||||
|
},
|
||||||
|
relations: ['author', 'category', 'updates'],
|
||||||
|
order: { updatedAt: 'DESC' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActive(): Promise<LiveBlog[]> {
|
||||||
|
return this.liveBlogRepository.find({
|
||||||
|
where: { status: LiveBlogStatus.LIVE },
|
||||||
|
relations: ['author', 'category'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { Controller, Post, Body } from '@nestjs/common';
|
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
|
import { Public } from './auth/public.decorator';
|
||||||
|
|
||||||
interface WebhookBody {
|
interface WebhookBody {
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete';
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish';
|
||||||
model: string;
|
model: string;
|
||||||
entry: {
|
entry: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -11,23 +17,126 @@ interface WebhookBody {
|
|||||||
|
|
||||||
@Controller('webhooks/strapi')
|
@Controller('webhooks/strapi')
|
||||||
export class StrapiController {
|
export class StrapiController {
|
||||||
|
private readonly logger = new Logger(StrapiController.name);
|
||||||
|
|
||||||
constructor(private readonly strapiService: StrapiService) {}
|
constructor(private readonly strapiService: StrapiService) {}
|
||||||
|
|
||||||
@Post('article')
|
@Post('article')
|
||||||
|
@Public()
|
||||||
async handleArticleWebhook(@Body() body: WebhookBody) {
|
async handleArticleWebhook(@Body() body: WebhookBody) {
|
||||||
|
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
|
||||||
const { event, model, entry } = body;
|
const { event, model, entry } = body;
|
||||||
|
|
||||||
if (model !== 'article') {
|
if (model !== 'article') {
|
||||||
|
this.logger.warn(`Ignored: not an article, model: ${model}`);
|
||||||
return { message: 'Ignored: not an article' };
|
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')
|
||||||
|
@Public()
|
||||||
|
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()
|
||||||
|
@Public()
|
||||||
|
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' };
|
return { message: 'Webhook processed successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/all')
|
@Post('sync/all')
|
||||||
async syncAllArticles() {
|
async syncAllArticles() {
|
||||||
await this.strapiService.syncArticles();
|
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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
import { StrapiController } from './strapi.controller';
|
import { StrapiController } from './strapi.controller';
|
||||||
import { ArticlesModule } from './articles.module';
|
import { ArticlesModule } from './articles.module';
|
||||||
|
import { LiveBlogModule } from './live-blog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule, ArticlesModule],
|
imports: [
|
||||||
|
HttpModule,
|
||||||
|
forwardRef(() => ArticlesModule),
|
||||||
|
forwardRef(() => LiveBlogModule),
|
||||||
|
],
|
||||||
controllers: [StrapiController],
|
controllers: [StrapiController],
|
||||||
providers: [StrapiService],
|
providers: [StrapiService],
|
||||||
exports: [StrapiService],
|
exports: [StrapiService],
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import { CreateArticleDto } from './articles.dto';
|
import { LiveBlogService } from './live-blog.service';
|
||||||
import { ArticleStatus } from './entities';
|
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
||||||
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
|
interface StrapiImage {
|
||||||
|
url: string;
|
||||||
|
alternativeText?: string;
|
||||||
|
caption?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
formats?: Record<string, StrapiImageFormat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StrapiImageFormat {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface StrapiArticle {
|
interface StrapiArticle {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +38,32 @@ interface StrapiArticle {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
img?: StrapiImage;
|
||||||
|
media?: StrapiImage[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StrapiLiveBlog {
|
||||||
|
id: number;
|
||||||
|
documentId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
status: 'draft' | 'live' | 'ended' | 'archived';
|
||||||
|
publishedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
img?: StrapiImage;
|
||||||
|
media?: StrapiImage[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrapiResponse<T> {
|
interface StrapiResponse<T> {
|
||||||
@@ -39,7 +87,10 @@ export class StrapiService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
|
@Inject(forwardRef(() => ArticlesService))
|
||||||
private readonly articlesService: ArticlesService,
|
private readonly articlesService: ArticlesService,
|
||||||
|
@Inject(forwardRef(() => LiveBlogService))
|
||||||
|
private readonly liveBlogService: LiveBlogService,
|
||||||
) {
|
) {
|
||||||
this.strapiUrl =
|
this.strapiUrl =
|
||||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
||||||
@@ -57,13 +108,67 @@ export class StrapiService {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
|
||||||
|
// Try to get image from img field first (single image)
|
||||||
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
|
if (strapiArticle.img?.url) {
|
||||||
|
imageUrl = strapiArticle.img.url;
|
||||||
|
} else if (strapiArticle.media?.[0]?.url) {
|
||||||
|
// Try to get first image from media field (multiple images)
|
||||||
|
imageUrl = strapiArticle.media[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL is relative, prepend Strapi base URL
|
||||||
|
if (imageUrl.startsWith('/')) {
|
||||||
|
// Convert Docker service URL to localhost for frontend access
|
||||||
|
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
||||||
|
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
||||||
|
return `${frontendStrapiUrl}${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractLiveBlogImageUrl(
|
||||||
|
strapiLiveBlog: StrapiLiveBlog,
|
||||||
|
): string | undefined {
|
||||||
|
// Try to get image from img field first (single image)
|
||||||
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
|
if (strapiLiveBlog.img?.url) {
|
||||||
|
imageUrl = strapiLiveBlog.img.url;
|
||||||
|
} else if (strapiLiveBlog.media?.[0]?.url) {
|
||||||
|
// Try to get first image from media field (multiple images)
|
||||||
|
imageUrl = strapiLiveBlog.media[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL is relative, prepend Strapi base URL
|
||||||
|
if (imageUrl.startsWith('/')) {
|
||||||
|
// Convert Docker service URL to localhost for frontend access
|
||||||
|
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
||||||
|
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
||||||
|
return `${frontendStrapiUrl}${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async syncArticles(): Promise<void> {
|
async syncArticles(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.logger.log('Starting articles sync from Strapi...');
|
this.logger.log('Starting articles sync from Strapi...');
|
||||||
|
|
||||||
const response = await lastValueFrom(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
||||||
`${this.strapiUrl}/api/articles`,
|
`${this.strapiUrl}/api/articles?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -74,6 +179,8 @@ export class StrapiService {
|
|||||||
let syncedCount = 0;
|
let syncedCount = 0;
|
||||||
|
|
||||||
for (const strapiArticle of strapiArticles) {
|
for (const strapiArticle of strapiArticles) {
|
||||||
|
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description,
|
||||||
@@ -83,6 +190,14 @@ export class StrapiService {
|
|||||||
? ArticleStatus.PUBLISHED
|
? ArticleStatus.PUBLISHED
|
||||||
: ArticleStatus.DRAFT,
|
: ArticleStatus.DRAFT,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
featuredImage: imageUrl,
|
||||||
|
imagePosition: (strapiArticle.imagePosition ||
|
||||||
|
'top') as ImagePosition,
|
||||||
|
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||||
|
videoUrl: strapiArticle.videoUrl || '',
|
||||||
|
videoPosition: (strapiArticle.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
|
videoCaption: strapiArticle.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.articlesService.syncFromStrapi(
|
await this.articlesService.syncFromStrapi(
|
||||||
@@ -101,13 +216,23 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSingleArticle(strapiId: string): Promise<void> {
|
async syncSingleArticle(
|
||||||
|
strapiId: string,
|
||||||
|
event?:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
|
): Promise<void> {
|
||||||
try {
|
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
||||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -115,42 +240,430 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const strapiArticle = response.data.data;
|
const strapiArticle = response.data.data;
|
||||||
|
|
||||||
|
// Determine status based on publishedAt and event type
|
||||||
|
let status: ArticleStatus;
|
||||||
|
if (event === 'entry.unpublish') {
|
||||||
|
// If it's an unpublish event, always mark as draft
|
||||||
|
status = ArticleStatus.DRAFT;
|
||||||
|
} else if (strapiArticle.publishedAt) {
|
||||||
|
// If publishedAt exists, it's published
|
||||||
|
status = ArticleStatus.PUBLISHED;
|
||||||
|
} else {
|
||||||
|
// Otherwise it's a draft
|
||||||
|
status = ArticleStatus.DRAFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||||
|
|
||||||
const articleData: Partial<CreateArticleDto> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description,
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug: strapiArticle.slug,
|
||||||
status: strapiArticle.publishedAt
|
status,
|
||||||
? ArticleStatus.PUBLISHED
|
|
||||||
: ArticleStatus.DRAFT,
|
|
||||||
tags: [],
|
tags: [],
|
||||||
|
featuredImage: imageUrl,
|
||||||
|
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
||||||
|
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||||
|
videoUrl: strapiArticle.videoUrl || '',
|
||||||
|
videoPosition: (strapiArticle.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
|
videoCaption: strapiArticle.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.articlesService.syncFromStrapi(
|
await this.articlesService.syncFromStrapi(
|
||||||
strapiArticle.documentId,
|
strapiArticle.documentId,
|
||||||
articleData,
|
articleData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
|
this.logger.log(
|
||||||
|
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||||
|
const axiosError = error as { response?: { status: number } };
|
||||||
|
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||||
|
this.logger.log(
|
||||||
|
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to update the article status to draft directly
|
||||||
|
try {
|
||||||
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
|
status: ArticleStatus.DRAFT,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.articlesService.syncFromStrapi(strapiId, articleData);
|
||||||
|
this.logger.log(
|
||||||
|
`Marked article ${strapiId} as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
} catch (syncError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to mark article ${strapiId} as draft:`,
|
||||||
|
syncError,
|
||||||
|
);
|
||||||
|
throw syncError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to sync article ${strapiId} from Strapi`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncLiveBlogs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log('Starting live blogs sync from Strapi...');
|
||||||
|
|
||||||
|
const response = await lastValueFrom(
|
||||||
|
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
||||||
|
`${this.strapiUrl}/api/live-blogs?populate=*`,
|
||||||
|
{
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const strapiLiveBlogs = response.data.data;
|
||||||
|
let syncedCount = 0;
|
||||||
|
|
||||||
|
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||||
|
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||||
|
|
||||||
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
|
title: strapiLiveBlog.title,
|
||||||
|
description: strapiLiveBlog.description,
|
||||||
|
slug: strapiLiveBlog.slug,
|
||||||
|
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
||||||
|
featuredImage: imageUrl,
|
||||||
|
imagePosition: (strapiLiveBlog.imagePosition ||
|
||||||
|
'top') as ImagePosition,
|
||||||
|
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||||
|
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||||
|
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
|
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.liveBlogService.syncFromStrapi(
|
||||||
|
strapiLiveBlog.documentId,
|
||||||
|
liveBlogData,
|
||||||
|
);
|
||||||
|
syncedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully synced ${syncedCount} live blogs from Strapi`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to sync live blogs from Strapi', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncSingleLiveBlog(
|
||||||
|
strapiId: string,
|
||||||
|
event?:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await lastValueFrom(
|
||||||
|
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||||
|
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
|
||||||
|
{
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const strapiLiveBlog = response.data.data;
|
||||||
|
|
||||||
|
// For live blogs, we use the status from Strapi directly
|
||||||
|
// but we might want to handle unpublish events differently
|
||||||
|
let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status);
|
||||||
|
|
||||||
|
// If it's an unpublish event, set to draft
|
||||||
|
if (event === 'entry.unpublish') {
|
||||||
|
status = LiveBlogStatus.DRAFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||||
|
|
||||||
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
|
title: strapiLiveBlog.title,
|
||||||
|
description: strapiLiveBlog.description,
|
||||||
|
slug: strapiLiveBlog.slug,
|
||||||
|
status,
|
||||||
|
featuredImage: imageUrl,
|
||||||
|
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
||||||
|
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||||
|
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||||
|
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
|
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.liveBlogService.syncFromStrapi(
|
||||||
|
strapiLiveBlog.documentId,
|
||||||
|
liveBlogData,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||||
|
const axiosError = error as { response?: { status: number } };
|
||||||
|
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||||
|
this.logger.log(
|
||||||
|
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to update the live blog status to draft directly
|
||||||
|
try {
|
||||||
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
|
status: LiveBlogStatus.DRAFT,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
|
||||||
|
this.logger.log(
|
||||||
|
`Marked live blog ${strapiId} as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
} catch (syncError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to mark live blog ${strapiId} as draft:`,
|
||||||
|
syncError,
|
||||||
|
);
|
||||||
|
throw syncError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to sync live blog ${strapiId} from Strapi`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'live':
|
||||||
|
return LiveBlogStatus.LIVE;
|
||||||
|
case 'draft':
|
||||||
|
return LiveBlogStatus.DRAFT;
|
||||||
|
case 'ended':
|
||||||
|
return LiveBlogStatus.ENDED;
|
||||||
|
case 'archived':
|
||||||
|
return LiveBlogStatus.ARCHIVED;
|
||||||
|
default:
|
||||||
|
return LiveBlogStatus.DRAFT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWebhook(
|
||||||
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| '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}, model: ${data.model}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.model === 'article') {
|
||||||
|
await this.articlesService.removeByStrapiId(data.documentId);
|
||||||
|
} else if (data.model === 'live-blog') {
|
||||||
|
await this.liveBlogService.removeByStrapiId(data.documentId);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to appropriate sync method based on model
|
||||||
|
if (data.model === 'article') {
|
||||||
|
await this.syncSingleArticle(data.documentId, event);
|
||||||
|
} else if (data.model === 'live-blog') {
|
||||||
|
await this.syncSingleLiveBlog(data.documentId, event);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateArticleStatusInStrapi(
|
||||||
|
strapiId: string,
|
||||||
|
status: ArticleStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Updating article ${strapiId} status in Strapi to: ${status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map our status to Strapi status
|
||||||
|
let strapiStatus: string;
|
||||||
|
switch (status) {
|
||||||
|
case ArticleStatus.DRAFT:
|
||||||
|
strapiStatus = 'draft';
|
||||||
|
break;
|
||||||
|
case ArticleStatus.PUBLISHED:
|
||||||
|
strapiStatus = 'published';
|
||||||
|
break;
|
||||||
|
case ArticleStatus.ARCHIVED:
|
||||||
|
strapiStatus = 'archived';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
strapiStatus = 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For archived status, we need to unpublish first if it's published
|
||||||
|
if (status === ArticleStatus.ARCHIVED) {
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.patch(
|
||||||
|
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
|
||||||
|
{},
|
||||||
|
{ headers: this.getHeaders() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.patch(
|
||||||
|
`${this.strapiUrl}/api/articles/${strapiId}`,
|
||||||
|
{ data: { status: strapiStatus } },
|
||||||
|
{ headers: this.getHeaders() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to sync article ${strapiId} from Strapi`,
|
`Failed to update article ${strapiId} status in Strapi:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleWebhook(
|
async updateLiveBlogStatusInStrapi(
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete',
|
strapiId: string,
|
||||||
data: { documentId: string },
|
status: LiveBlogStatus,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`Received webhook event: ${event}`);
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (event === 'entry.delete') {
|
// Map our status to Strapi status
|
||||||
this.logger.log(`Handling delete for document: ${data.documentId}`);
|
let strapiStatus: string;
|
||||||
return;
|
switch (status) {
|
||||||
|
case LiveBlogStatus.DRAFT:
|
||||||
|
strapiStatus = 'draft';
|
||||||
|
break;
|
||||||
|
case LiveBlogStatus.LIVE:
|
||||||
|
strapiStatus = 'live';
|
||||||
|
break;
|
||||||
|
case LiveBlogStatus.ENDED:
|
||||||
|
strapiStatus = 'ended';
|
||||||
|
break;
|
||||||
|
case LiveBlogStatus.ARCHIVED:
|
||||||
|
strapiStatus = 'archived';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
strapiStatus = 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For archived status, we need to unpublish first if it's published
|
||||||
|
if (status === LiveBlogStatus.ARCHIVED) {
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.patch(
|
||||||
|
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
|
||||||
|
{},
|
||||||
|
{ headers: this.getHeaders() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.patch(
|
||||||
|
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||||
|
{ data: { status: strapiStatus } },
|
||||||
|
{ headers: this.getHeaders() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update live blog ${strapiId} status in Strapi:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.syncSingleArticle(data.documentId);
|
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Deleting article ${strapiId} from Strapi`);
|
||||||
|
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete article ${strapiId} from Strapi:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
|
||||||
|
|
||||||
|
await lastValueFrom(
|
||||||
|
this.httpService.delete(
|
||||||
|
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||||
|
{ headers: this.getHeaders() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete live blog ${strapiId} from Strapi:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
import {
|
||||||
|
CreateUserDto,
|
||||||
|
UpdateUserDto,
|
||||||
|
UserResponseDto,
|
||||||
|
ChangePasswordDto,
|
||||||
|
} from './user.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
import type { RequestWithUser } from '../auth/types';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UserController {
|
||||||
|
constructor(private readonly userService: UserService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||||
|
return this.userService.create(createUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async findAll(): Promise<UserResponseDto[]> {
|
||||||
|
return this.userService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getProfile(@Request() req: RequestWithUser): Promise<UserResponseDto> {
|
||||||
|
return this.userService.findOne(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
|
||||||
|
return this.userService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
): Promise<UserResponseDto> {
|
||||||
|
return this.userService.update(id, updateUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@Param('id') id: string): Promise<void> {
|
||||||
|
return this.userService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/change-password')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async changePassword(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() changePasswordDto: ChangePasswordDto,
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
): Promise<void> {
|
||||||
|
// Users can only change their own password, unless they're admin
|
||||||
|
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
|
||||||
|
throw new Error('You can only change your own password');
|
||||||
|
}
|
||||||
|
return this.userService.changePassword(id, changePasswordDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/deactivate')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async deactivate(@Param('id') id: string): Promise<UserResponseDto> {
|
||||||
|
return this.userService.update(id, { isActive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/activate')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async activate(@Param('id') id: string): Promise<UserResponseDto> {
|
||||||
|
return this.userService.update(id, { isActive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
interface UserEntity {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
role: UserRole;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(30)
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||||
|
message: 'Username can only contain letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
@IsOptional()
|
||||||
|
role?: UserRole = UserRole.USER;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@IsEmail()
|
||||||
|
@IsOptional()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(30)
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||||
|
message: 'Username can only contain letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@IsOptional()
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
@IsOptional()
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginUserDto {
|
||||||
|
@IsString()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserResponseDto {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
role: UserRole;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(user: UserEntity) {
|
||||||
|
this.id = user.id;
|
||||||
|
this.email = user.email;
|
||||||
|
this.username = user.username;
|
||||||
|
this.role = user.role;
|
||||||
|
this.isActive = user.isActive;
|
||||||
|
this.createdAt = user.createdAt;
|
||||||
|
this.updatedAt = user.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
currentPassword: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { User } from '../entities';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
controllers: [UserController],
|
||||||
|
providers: [UserService],
|
||||||
|
exports: [UserService],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User, UserRole } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateUserDto,
|
||||||
|
UpdateUserDto,
|
||||||
|
UserResponseDto,
|
||||||
|
ChangePasswordDto,
|
||||||
|
} from './user.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||||
|
// Check if email already exists
|
||||||
|
const existingEmail = await this.userRepository.findOne({
|
||||||
|
where: { email: createUserDto.email },
|
||||||
|
});
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new ConflictException('Email already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
const existingUsername = await this.userRepository.findOne({
|
||||||
|
where: { username: createUserDto.username },
|
||||||
|
});
|
||||||
|
if (existingUsername) {
|
||||||
|
throw new ConflictException('Username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const saltRounds = 10;
|
||||||
|
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
...createUserDto,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
return new UserResponseDto(savedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<UserResponseDto[]> {
|
||||||
|
const users = await this.userRepository.find({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
return users.map((user) => new UserResponseDto(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
return new UserResponseDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUsername(username: string): Promise<User | null> {
|
||||||
|
return this.userRepository.findOne({ where: { username } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return this.userRepository.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updateUserDto: UpdateUserDto,
|
||||||
|
): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is being changed and if it already exists
|
||||||
|
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||||||
|
const existingEmail = await this.userRepository.findOne({
|
||||||
|
where: { email: updateUserDto.email },
|
||||||
|
});
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new ConflictException('Email already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is being changed and if it already exists
|
||||||
|
if (updateUserDto.username && updateUserDto.username !== user.username) {
|
||||||
|
const existingUsername = await this.userRepository.findOne({
|
||||||
|
where: { username: updateUserDto.username },
|
||||||
|
});
|
||||||
|
if (existingUsername) {
|
||||||
|
throw new ConflictException('Username already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password if provided
|
||||||
|
if (updateUserDto.password) {
|
||||||
|
const saltRounds = 10;
|
||||||
|
updateUserDto.password = await bcrypt.hash(
|
||||||
|
updateUserDto.password,
|
||||||
|
saltRounds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
Object.assign(user, updateUserDto);
|
||||||
|
if (updateUserDto.password) {
|
||||||
|
user.passwordHash = updateUserDto.password;
|
||||||
|
delete updateUserDto.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await this.userRepository.save(user);
|
||||||
|
return new UserResponseDto(updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the last admin
|
||||||
|
if (user.role === UserRole.ADMIN) {
|
||||||
|
const adminCount = await this.userRepository.count({
|
||||||
|
where: { role: UserRole.ADMIN },
|
||||||
|
});
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new BadRequestException('Cannot delete the last admin user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.remove(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(
|
||||||
|
id: string,
|
||||||
|
changePasswordDto: ChangePasswordDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isPasswordValid = await bcrypt.compare(
|
||||||
|
changePasswordDto.currentPassword,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new BadRequestException('Current password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const saltRounds = 10;
|
||||||
|
user.passwordHash = await bcrypt.hash(
|
||||||
|
changePasswordDto.newPassword,
|
||||||
|
saltRounds,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUser(username: string, password: string): Promise<User | null> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { username, isActive: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminIfNotExists(): Promise<void> {
|
||||||
|
const adminExists = await this.userRepository.findOne({
|
||||||
|
where: { role: UserRole.ADMIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminExists) {
|
||||||
|
const saltRounds = 10;
|
||||||
|
const passwordHash = await bcrypt.hash('admin123', saltRounds);
|
||||||
|
|
||||||
|
const adminUser = this.userRepository.create({
|
||||||
|
email: 'admin@placebo.mk',
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(adminUser);
|
||||||
|
console.log('Default admin user created: admin / admin123');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-cloud": "5.33.0",
|
||||||
"@strapi/plugin-users-permissions": "5.33.0",
|
"@strapi/plugin-users-permissions": "5.33.0",
|
||||||
"@strapi/strapi": "5.33.0",
|
"@strapi/strapi": "5.33.0",
|
||||||
"better-sqlite3": "12.4.1",
|
"pg": "^8.13.3",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-router-dom": "^6.0.0",
|
"react-router-dom": "^6.0.0",
|
||||||
|
|||||||
@@ -29,6 +29,40 @@
|
|||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"img": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": false,
|
||||||
|
"allowedTypes": [
|
||||||
|
"images",
|
||||||
|
"files",
|
||||||
|
"videos",
|
||||||
|
"audios"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"imagePosition": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["top", "left", "right", "none"],
|
||||||
|
"default": "top"
|
||||||
|
},
|
||||||
|
"imageSize": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["small", "medium", "large"],
|
||||||
|
"default": "medium"
|
||||||
|
},
|
||||||
|
"videoUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"videoPosition": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["top", "inline", "bottom", "none"],
|
||||||
|
"default": "inline"
|
||||||
|
},
|
||||||
|
"videoCaption": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
@@ -446,6 +446,13 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
|||||||
createdAt: Schema.Attribute.DateTime;
|
createdAt: Schema.Attribute.DateTime;
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
|
imagePosition: Schema.Attribute.Enumeration<
|
||||||
|
['top', 'left', 'right', 'none']
|
||||||
|
> &
|
||||||
|
Schema.Attribute.DefaultTo<'top'>;
|
||||||
|
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
|
||||||
|
Schema.Attribute.DefaultTo<'medium'>;
|
||||||
|
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||||
localizations: Schema.Attribute.Relation<
|
localizations: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
@@ -461,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
|||||||
updatedAt: Schema.Attribute.DateTime;
|
updatedAt: Schema.Attribute.DateTime;
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
|
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||||
|
videoPosition: Schema.Attribute.Enumeration<
|
||||||
|
['top', 'inline', 'bottom', 'none']
|
||||||
|
> &
|
||||||
|
Schema.Attribute.DefaultTo<'inline'>;
|
||||||
|
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.docker # Use the .env.docker file for Docker configuration
|
||||||
|
environment:
|
||||||
|
# Only override if needed, most config is in .env.docker 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.docker:/app/.env # Mount .env.docker file as .env in 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,149 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for production
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: placebo-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: placebo_backend_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_backend_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_backend_db
|
||||||
|
DATABASE_SYNCHRONIZE: "false"
|
||||||
|
DATABASE_LOGGING: "false"
|
||||||
|
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
|
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
+2721
-22
File diff suppressed because it is too large.
Load diff
+12
-2
@@ -12,15 +12,25 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"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": {
|
"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-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"@tanstack/react-router": "^1.144.0",
|
"@tanstack/react-router": "^1.144.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
+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}`}
|
||||||
|
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}`}
|
||||||
|
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,94 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onSwitchToRegister?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||||
|
const { login, isLoading } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your credentials to access your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSwitchToRegister && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Register here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from '@tanstack/react-router';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requiredRole?: 'admin' | 'contributor' | 'user';
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({
|
||||||
|
children,
|
||||||
|
requiredRole,
|
||||||
|
redirectTo = '/'
|
||||||
|
}: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-4 text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={redirectTo} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRole && user?.role !== requiredRole) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onSwitchToLogin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||||
|
const { register, isLoading } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(username, email, password);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Register</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new account to start commenting and reacting
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Choose a username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Choose a password (min 6 characters)"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating account...' : 'Register'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSwitchToLogin && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Login here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useCreateComment } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { Textarea } from '../../ui/textarea';
|
||||||
|
import { Card, CardContent } from '../../ui/card';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { mk } from 'date-fns/locale';
|
||||||
|
import type { Comment } from '../../../lib/api';
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: Comment;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
|
const [replyContent, setReplyContent] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
|
||||||
|
const createCommentMutation = useCreateComment();
|
||||||
|
|
||||||
|
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!replyContent.trim() || !isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
await createCommentMutation.mutateAsync({
|
||||||
|
content: replyContent,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
parentCommentId: comment.id,
|
||||||
|
});
|
||||||
|
setReplyContent('');
|
||||||
|
setShowReplyForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post reply:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maximum depth to prevent infinite nesting (optional)
|
||||||
|
const maxDepth = 5;
|
||||||
|
const canReply = depth < maxDepth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{comment.user?.username || 'Анонимен корисник'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comment.user?.role === 'admin' && (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||||
|
Администратор
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
|
||||||
|
|
||||||
|
{/* Reply button and form */}
|
||||||
|
{isAuthenticated && canReply && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{!showReplyForm ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowReplyForm(true)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Одговори
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
|
||||||
|
<form onSubmit={handleSubmitReply}>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Вашиот одговор..."
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
className="min-h-[80px] mb-3"
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowReplyForm(false);
|
||||||
|
setReplyContent('');
|
||||||
|
}}
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
>
|
||||||
|
Откажи
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!replyContent.trim() || isSubmittingReply}
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions (if implemented) */}
|
||||||
|
{comment.reactions && (
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Render replies recursively */}
|
||||||
|
{comment.replies && comment.replies.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<CommentItem
|
||||||
|
key={reply.id}
|
||||||
|
comment={reply}
|
||||||
|
articleId={articleId}
|
||||||
|
liveBlogId={liveBlogId}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useComments, useCreateComment } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { Textarea } from '../../ui/textarea';
|
||||||
|
import { Card, CardContent } from '../../ui/card';
|
||||||
|
import { CommentItem } from './CommentItem';
|
||||||
|
|
||||||
|
interface CommentSectionProps {
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { data: commentsData, isLoading } = useComments({
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCommentMutation = useCreateComment();
|
||||||
|
|
||||||
|
const comments = commentsData?.data || [];
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!newComment.trim() || !isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createCommentMutation.mutateAsync({
|
||||||
|
content: newComment,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
});
|
||||||
|
setNewComment('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post comment:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 pt-8 border-t">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmitComment}>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Што мислите за овој напис?"
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
className="min-h-[100px] mb-4"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newComment.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Најавете се за да можете да коментирате
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/auth">Најави се</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Сè уште нема коментари. Бидете првиот што ќе коментира!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
articleId={articleId}
|
||||||
|
liveBlogId={liveBlogId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ReactionButtonsProps {
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactionButtons({
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
commentId,
|
||||||
|
compact = false
|
||||||
|
}: ReactionButtonsProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
|
||||||
|
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
|
||||||
|
const addReactionMutation = useAddReaction();
|
||||||
|
|
||||||
|
const userReactionType = userReaction?.type;
|
||||||
|
|
||||||
|
const handleReaction = async (type: 'like' | 'dislike') => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
window.location.href = '/auth';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addReactionMutation.mutateAsync({
|
||||||
|
type,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
commentId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add reaction:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const likes = counts?.likes || 0;
|
||||||
|
const dislikes = counts?.dislikes || 0;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction('like')}
|
||||||
|
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||||
|
{likes > 0 && <span>{likes}</span>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction('dislike')}
|
||||||
|
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||||
|
{dislikes > 0 && <span>{dislikes}</span>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={userReactionType === 'like' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction('like')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-4 h-4" />
|
||||||
|
<span>Допаѓа ми</span>
|
||||||
|
{likes > 0 && (
|
||||||
|
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{likes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction('dislike')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ThumbsDown className="w-4 h-4" />
|
||||||
|
<span>Не ми се допаѓа</span>
|
||||||
|
{dislikes > 0 && (
|
||||||
|
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{dislikes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</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,65 @@
|
|||||||
|
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { user, logout, isAuthenticated, hasRole } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<nav className="flex items-center 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>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
{(hasRole('admin') || hasRole('contributor')) && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user?.username}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={logout}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/auth" className="text-sm font-medium hover:underline text-primary">
|
||||||
|
Login / Register
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useLiveBlogs } from '@/queries/live-blogs';
|
||||||
|
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles';
|
||||||
|
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { mk } from 'date-fns/locale';
|
||||||
|
|
||||||
|
export function AdminDashboardComponent() {
|
||||||
|
// State for confirmation dialog and filters
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<{
|
||||||
|
type: 'article' | 'liveBlog';
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
|
||||||
|
limit: 50,
|
||||||
|
status: showArchived ? 'archived' : 'draft,live,ended'
|
||||||
|
});
|
||||||
|
const { data: articlesData, isLoading: loadingArticles } = useArticles({
|
||||||
|
limit: 50,
|
||||||
|
status: showArchived ? 'archived' : 'draft,published'
|
||||||
|
});
|
||||||
|
const deleteArticleMutation = useDeleteArticle();
|
||||||
|
const deleteLiveBlogMutation = useDeleteLiveBlog();
|
||||||
|
const archiveArticleMutation = useArchiveArticle();
|
||||||
|
const archiveLiveBlogMutation = useArchiveLiveBlog();
|
||||||
|
const publishArticleMutation = usePublishArticle();
|
||||||
|
const publishLiveBlogMutation = usePublishLiveBlog();
|
||||||
|
|
||||||
|
const liveBlogs = liveBlogsData?.data || [];
|
||||||
|
const articles = articlesData?.data || [];
|
||||||
|
|
||||||
|
// No need to filter items - API already filters based on showArchived state
|
||||||
|
const filteredLiveBlogs = liveBlogs;
|
||||||
|
const filteredArticles = articles;
|
||||||
|
|
||||||
|
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||||
|
setItemToDelete({ type, id, title });
|
||||||
|
setDialogType('delete');
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||||
|
setItemToDelete({ type, id, title });
|
||||||
|
setDialogType('archive');
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
if (type === 'article') {
|
||||||
|
await publishArticleMutation.mutateAsync({ id, status: 'published' });
|
||||||
|
} else {
|
||||||
|
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish:', error);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAction = async () => {
|
||||||
|
if (!itemToDelete) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
if (dialogType === 'delete') {
|
||||||
|
if (itemToDelete.type === 'article') {
|
||||||
|
await deleteArticleMutation.mutateAsync(itemToDelete.id);
|
||||||
|
} else {
|
||||||
|
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||||
|
}
|
||||||
|
} else { // archive
|
||||||
|
if (itemToDelete.type === 'article') {
|
||||||
|
await archiveArticleMutation.mutateAsync(itemToDelete.id);
|
||||||
|
} else {
|
||||||
|
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ${dialogType}:`, error);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelAction = () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published':
|
||||||
|
case 'live':
|
||||||
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
|
case 'draft':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'archived':
|
||||||
|
case 'ended':
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
default:
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published': return 'Објавено';
|
||||||
|
case 'draft': return 'Нацрт';
|
||||||
|
case 'archived': return 'Архивирано';
|
||||||
|
case 'live': return 'Во живо';
|
||||||
|
case 'ended': return 'Завршено';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-8 space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Управување со сите написи и live блогови
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={showArchived ? "default" : "outline"}
|
||||||
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
|
>
|
||||||
|
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">Назад кон сајтот</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Live Blogs Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{filteredLiveBlogs.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Сите live блогови со статус и датум на креирање
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingLiveBlogs ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredLiveBlogs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border rounded-lg">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||||
|
</p>
|
||||||
|
{!showArchived && (
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredLiveBlogs.map((blog) => (
|
||||||
|
<div
|
||||||
|
key={blog.id}
|
||||||
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to="/admin/live-blogs/$slug"
|
||||||
|
params={{ slug: blog.slug }}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{blog.title}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className={getStatusColor(blog.status)}>
|
||||||
|
{getStatusText(blog.status)}
|
||||||
|
</Badge>
|
||||||
|
{blog.isPinned && (
|
||||||
|
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||||||
|
Закачено
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Слаг: {blog.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Прегледи: {blog.viewCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="ghost">
|
||||||
|
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
|
||||||
|
Преглед
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{showArchived ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Објави
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Архивирај
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Избриши
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Articles Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{filteredArticles.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Сите написи со статус и датум на креирање
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingArticles ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredArticles.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border rounded-lg">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||||
|
</p>
|
||||||
|
{!showArchived && (
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/">Креирај нов напис</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredArticles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||||||
|
{getStatusText(article.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Слаг: {article.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Прегледи: {article.views}</span>
|
||||||
|
</div>
|
||||||
|
{article.excerpt && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="ghost">
|
||||||
|
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||||
|
Преглед
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{showArchived ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handlePublishClick('article', article.id)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Објави
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Архивирај
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Избриши
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats - Only show when not viewing archived items */}
|
||||||
|
{!showArchived && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.filter(a => a.status === 'published').length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||||
|
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
{showConfirmDialog && itemToDelete && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{dialogType === 'delete'
|
||||||
|
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||||
|
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||||
|
Откажи
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={dialogType === 'delete' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleConfirmAction}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing
|
||||||
|
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||||||
|
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import * as api from '@/lib/api'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||||
|
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||||
|
import { CommentSection } from '@/components/features/comments/CommentSection'
|
||||||
|
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
|
||||||
|
|
||||||
|
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['article', id],
|
||||||
|
queryFn: () => api.fetchArticleById(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-lg">Loading article...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-lg text-red-500">Error loading article</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-lg">Article not found</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="max-w-3xl mx-auto">
|
||||||
|
<Link
|
||||||
|
to="/articles"
|
||||||
|
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
<path d="M19 6H5" />
|
||||||
|
</svg>
|
||||||
|
Back to articles
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
|
||||||
|
<span>
|
||||||
|
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{data.views} views</span>
|
||||||
|
{data.author && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>By {data.author.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||||
|
<div className={`relative mb-4 ${
|
||||||
|
data.imagePosition === 'top'
|
||||||
|
? 'w-full mb-8'
|
||||||
|
: data.imagePosition === 'left'
|
||||||
|
? 'float-none md:float-left mr-0 md:mr-6'
|
||||||
|
: 'float-none md:float-right ml-0 md:ml-6'
|
||||||
|
}`}>
|
||||||
|
<img
|
||||||
|
src={data.featuredImage}
|
||||||
|
alt={data.title}
|
||||||
|
className={`rounded-xl object-cover ${
|
||||||
|
data.imagePosition === 'top'
|
||||||
|
? data.imageSize === 'small'
|
||||||
|
? 'h-32'
|
||||||
|
: data.imageSize === 'medium'
|
||||||
|
? 'h-48'
|
||||||
|
: 'h-64 md:h-96'
|
||||||
|
: data.imageSize === 'small'
|
||||||
|
? 'w-full md:w-48 h-32'
|
||||||
|
: data.imageSize === 'medium'
|
||||||
|
? 'w-full md:w-64 h-48'
|
||||||
|
: 'w-full md:w-96 h-64'
|
||||||
|
}`}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Failed to load image:', data.featuredImage, e);
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
// Show fallback
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) {
|
||||||
|
fallback.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
>
|
||||||
|
<span>Image not available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video rendering */}
|
||||||
|
{data.videoUrl && data.videoPosition !== 'none' && (
|
||||||
|
<div className={getVideoPositionClasses(data.videoPosition)}>
|
||||||
|
<YouTubeEmbed
|
||||||
|
url={data.videoUrl}
|
||||||
|
title={data.title}
|
||||||
|
caption={data.videoCaption}
|
||||||
|
autoplay={false}
|
||||||
|
controls={true}
|
||||||
|
modestbranding={true}
|
||||||
|
showRelated={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose prose-slate max-w-none">
|
||||||
|
<div className="text-lg leading-relaxed mb-6">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
img: (props) => (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
className="max-w-full h-auto rounded-lg my-4"
|
||||||
|
alt={props.alt || 'Article image'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: (props) => {
|
||||||
|
// Check if the link is a YouTube URL
|
||||||
|
const videoId = extractYouTubeVideoId(props.href || '');
|
||||||
|
if (videoId) {
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<YouTubeEmbed
|
||||||
|
url={props.href || ''}
|
||||||
|
title={props.title || 'YouTube video'}
|
||||||
|
autoplay={false}
|
||||||
|
controls={true}
|
||||||
|
modestbranding={true}
|
||||||
|
showRelated={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Regular link
|
||||||
|
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||||
|
<div className="mt-8 pt-8 border-t">
|
||||||
|
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
|
<div className="mt-8 pt-8 border-t">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
|
||||||
|
<ReactionButtons articleId={data.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<CommentSection articleId={data.id} />
|
||||||
|
</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}`}
|
||||||
|
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,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { LoginForm } from '../auth/LoginForm';
|
||||||
|
import { RegisterForm } from '../auth/RegisterForm';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Navigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export function AuthPage() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">
|
||||||
|
Welcome to <span className="text-primary">Placebo.mk</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{isLogin
|
||||||
|
? 'Login to comment, react, and access admin features'
|
||||||
|
: 'Join our community of sarcastic news enthusiasts'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLogin ? (
|
||||||
|
<LoginForm
|
||||||
|
onSuccess={() => window.location.href = '/'}
|
||||||
|
onSwitchToRegister={() => setIsLogin(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RegisterForm
|
||||||
|
onSuccess={() => window.location.href = '/'}
|
||||||
|
onSwitchToLogin={() => setIsLogin(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
|
||||||
|
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Need help?{' '}
|
||||||
|
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
|
||||||
|
Contact support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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}`}
|
||||||
|
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 * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "./button-variants"
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
@@ -53,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
)
|
)
|
||||||
Button.displayName = "Button"
|
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,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
|
||||||
|
|
||||||
|
interface YouTubeEmbedProps {
|
||||||
|
/** YouTube video URL or video ID */
|
||||||
|
url: string;
|
||||||
|
/** Video title for accessibility */
|
||||||
|
title?: string;
|
||||||
|
/** Optional caption below the video */
|
||||||
|
caption?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Autoplay the video (muted on mobile) */
|
||||||
|
autoplay?: boolean;
|
||||||
|
/** Show video controls */
|
||||||
|
controls?: boolean;
|
||||||
|
/** Reduce YouTube branding */
|
||||||
|
modestbranding?: boolean;
|
||||||
|
/** Show related videos at the end */
|
||||||
|
showRelated?: boolean;
|
||||||
|
/** Start time in seconds */
|
||||||
|
startTime?: number;
|
||||||
|
/** End time in seconds */
|
||||||
|
endTime?: number;
|
||||||
|
/** Show loading state */
|
||||||
|
showLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YouTubeEmbed({
|
||||||
|
url,
|
||||||
|
title = 'YouTube video',
|
||||||
|
caption,
|
||||||
|
className,
|
||||||
|
autoplay = false,
|
||||||
|
controls = true,
|
||||||
|
modestbranding = true,
|
||||||
|
showRelated = false,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
showLoading = true,
|
||||||
|
}: YouTubeEmbedProps) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
// Extract video ID from URL
|
||||||
|
const videoId = extractYouTubeVideoId(url);
|
||||||
|
|
||||||
|
if (!videoId) {
|
||||||
|
return (
|
||||||
|
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
Invalid YouTube URL: {url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate embed URL with options
|
||||||
|
const embedUrl = generateYouTubeEmbedUrl(videoId, {
|
||||||
|
autoplay,
|
||||||
|
controls,
|
||||||
|
modestbranding,
|
||||||
|
rel: showRelated,
|
||||||
|
playsinline: true,
|
||||||
|
start: startTime,
|
||||||
|
end: endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{/* Video container with aspect ratio */}
|
||||||
|
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
|
||||||
|
{/* Loading state */}
|
||||||
|
{showLoading && !isLoaded && !hasError && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
|
||||||
|
<div className="text-destructive mb-2">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive font-medium">Video failed to load</p>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
The YouTube video could not be loaded. Please check the URL or try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube iframe */}
|
||||||
|
{!hasError && (
|
||||||
|
<iframe
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
|
||||||
|
!isLoaded && 'opacity-0'
|
||||||
|
)}
|
||||||
|
src={embedUrl}
|
||||||
|
title={title}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caption */}
|
||||||
|
{caption && (
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||||
|
{caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import * as api from '../lib/api';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
hasRole: (role: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAuth = () => {
|
||||||
|
const storedToken = localStorage.getItem('token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
try {
|
||||||
|
setToken(storedToken);
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse stored user:', error);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await api.login({ username, password });
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(response.user));
|
||||||
|
setToken(response.access_token);
|
||||||
|
setUser(response.user);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (username: string, email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await api.register({ username, email, password });
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(response.user));
|
||||||
|
setToken(response.access_token);
|
||||||
|
setUser(response.user);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
api.logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthenticated = !!user && !!token;
|
||||||
|
|
||||||
|
const hasRole = (role: string) => {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.role === role;
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
isAuthenticated,
|
||||||
|
hasRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user