Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca05f5ea1 | ||
|
|
89b8687431 | ||
|
|
3bc534489d | ||
|
|
6467e21019 | ||
|
|
28609a6492 | ||
|
|
7d3bc2a014 | ||
|
|
acfbc80d1a |
No files matched your search
@@ -0,0 +1,188 @@
|
|||||||
|
# Placebo.mk - Development Setup
|
||||||
|
|
||||||
|
This project supports two development modes:
|
||||||
|
1. **Docker Mode** (Recommended): All services run in Docker containers
|
||||||
|
2. **Local Mode**: Services run locally with npm, PostgreSQL in Docker
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Docker Mode (Recommended)
|
||||||
|
```bash
|
||||||
|
# Start all services in Docker
|
||||||
|
npm run docker:up
|
||||||
|
|
||||||
|
# Or use the alias
|
||||||
|
npm run dev:docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Services will be available at:
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:3000
|
||||||
|
- CMS (Strapi): http://localhost:1337/admin
|
||||||
|
- PostgreSQL: localhost:5432
|
||||||
|
|
||||||
|
### Option 2: Local Mode
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL in Docker
|
||||||
|
docker-compose -f docker-compose.dev.yml up postgres
|
||||||
|
|
||||||
|
# In another terminal, start backend and frontend locally
|
||||||
|
npm run dev:local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Backend Configuration
|
||||||
|
The backend uses `.env` files for configuration:
|
||||||
|
|
||||||
|
- `.env` - Default Docker configuration (committed)
|
||||||
|
- `.env.local` - Local development configuration (not committed)
|
||||||
|
- `.env.docker` - Docker configuration template
|
||||||
|
- `.env.example` - Example configuration
|
||||||
|
|
||||||
|
**Switching between modes:**
|
||||||
|
```bash
|
||||||
|
# Switch to local mode
|
||||||
|
cd backend && npm run dev:local
|
||||||
|
|
||||||
|
# Switch back to Docker mode
|
||||||
|
cd backend && npm run dev:reset-env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Configuration
|
||||||
|
The frontend also uses `.env` files:
|
||||||
|
|
||||||
|
- `.env` - Default Docker configuration (committed)
|
||||||
|
- `.env.local` - Local development configuration (not committed)
|
||||||
|
- `.env.docker` - Docker configuration template
|
||||||
|
|
||||||
|
**API URL differences:**
|
||||||
|
- Docker: `VITE_API_URL=http://backend:3000/api/v1`
|
||||||
|
- Local: `VITE_API_URL=http://localhost:3000/api/v1`
|
||||||
|
|
||||||
|
## Development Scripts
|
||||||
|
|
||||||
|
### Root Level Scripts
|
||||||
|
```bash
|
||||||
|
# Docker commands
|
||||||
|
npm run docker:up # Start all services
|
||||||
|
npm run docker:down # Stop all services
|
||||||
|
npm run docker:build # Rebuild containers
|
||||||
|
npm run docker:logs # View logs
|
||||||
|
npm run docker:ps # Check container status
|
||||||
|
|
||||||
|
# Development modes
|
||||||
|
npm run dev:docker # Docker mode (alias for docker:up)
|
||||||
|
npm run dev:local # Local mode
|
||||||
|
|
||||||
|
# Utility
|
||||||
|
npm run reset:env # Reset .env files to Docker defaults
|
||||||
|
npm run lint # Run linting on all services
|
||||||
|
npm run type-check # Run TypeScript checks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Scripts (in backend/ directory)
|
||||||
|
```bash
|
||||||
|
npm run dev:docker # Start in Docker mode (uses .env)
|
||||||
|
npm run dev:local # Start in local mode (uses .env.local)
|
||||||
|
npm run start:dev # Original NestJS dev command
|
||||||
|
npm run dev:reset-env # Reset .env to Docker defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Scripts (in frontend/ directory)
|
||||||
|
```bash
|
||||||
|
npm run dev:docker # Start in Docker mode
|
||||||
|
npm run dev:local # Start in local mode
|
||||||
|
npm run dev # Original Vite dev command
|
||||||
|
npm run dev:reset-env # Reset .env to Docker defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Management
|
||||||
|
|
||||||
|
### PostgreSQL in Docker
|
||||||
|
The PostgreSQL container is configured with:
|
||||||
|
- Host: `localhost:5432` (from host) or `postgres:5432` (from Docker)
|
||||||
|
- Database: `placebo_backend_db` (backend), `placebo_cms_db` (CMS)
|
||||||
|
- User: `placebo_user`
|
||||||
|
- Password: `placebo_password`
|
||||||
|
|
||||||
|
### Connecting to Database
|
||||||
|
```bash
|
||||||
|
# From host machine
|
||||||
|
psql -h localhost -p 5432 -U placebo_user -d placebo_backend_db
|
||||||
|
|
||||||
|
# From within Docker container
|
||||||
|
psql -h postgres -p 5432 -U placebo_user -d placebo_backend_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
placeboMk/
|
||||||
|
├── backend/ # NestJS backend API
|
||||||
|
│ ├── .env # Docker configuration (default)
|
||||||
|
│ ├── .env.local # Local development configuration
|
||||||
|
│ └── src/
|
||||||
|
├── frontend/ # React/Vite frontend
|
||||||
|
│ ├── .env # Docker configuration (default)
|
||||||
|
│ ├── .env.local # Local development configuration
|
||||||
|
│ └── src/
|
||||||
|
├── cms/cms/ # Strapi CMS
|
||||||
|
├── docker-compose.yml # Production configuration
|
||||||
|
├── docker-compose.dev.yml # Development configuration
|
||||||
|
└── package.json # Root scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Port conflicts**
|
||||||
|
- Check if ports 3000, 5173, 1337, 5432 are free
|
||||||
|
- Use `lsof -i :3000` to check what's using a port
|
||||||
|
|
||||||
|
2. **Database connection errors**
|
||||||
|
- Ensure PostgreSQL container is running: `docker ps`
|
||||||
|
- Check logs: `docker logs placebo-postgres-dev`
|
||||||
|
- Verify credentials in `.env` files
|
||||||
|
|
||||||
|
3. **Environment configuration**
|
||||||
|
- Run `npm run reset:env` to reset to Docker defaults
|
||||||
|
- Ensure `.env.local` exists for local development
|
||||||
|
|
||||||
|
4. **Docker container issues**
|
||||||
|
- Rebuild containers: `npm run docker:build`
|
||||||
|
- Clear Docker cache: `docker system prune -f`
|
||||||
|
|
||||||
|
### Reset Everything
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers
|
||||||
|
npm run docker:down
|
||||||
|
docker system prune -f
|
||||||
|
|
||||||
|
# Reset environment files
|
||||||
|
npm run reset:env
|
||||||
|
|
||||||
|
# Start fresh
|
||||||
|
npm run docker:up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production, use:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Production configuration:
|
||||||
|
- Uses separate `docker-compose.yml`
|
||||||
|
- Includes Nginx reverse proxy
|
||||||
|
- No hot reload, optimized builds
|
||||||
|
- Health checks and proper logging
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Docker-first approach**: `.env` files are configured for Docker by default
|
||||||
|
- **Local development**: Uses `.env.local` files when running services locally
|
||||||
|
- **PostgreSQL**: Always runs in Docker for consistency
|
||||||
|
- **Secrets**: Never commit actual secrets to repository
|
||||||
+31
-4
@@ -1,7 +1,34 @@
|
|||||||
PORT=3000
|
# 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
|
||||||
@@ -19,7 +19,10 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
FindArticlesDto,
|
FindArticlesDto,
|
||||||
} from './articles.dto';
|
} from './articles.dto';
|
||||||
|
import { ArticleStatus } from './entities';
|
||||||
|
|
||||||
@Controller('articles')
|
@Controller('articles')
|
||||||
export class ArticlesController {
|
export class ArticlesController {
|
||||||
@@ -54,4 +56,17 @@ export class ArticlesController {
|
|||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.articlesService.remove(id);
|
return this.articlesService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/archive')
|
||||||
|
archive(@Param('id') id: string) {
|
||||||
|
return this.articlesService.archive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/publish')
|
||||||
|
publish(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('status') status?: ArticleStatus,
|
||||||
|
) {
|
||||||
|
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDate,
|
IsDate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
export class CreateArticleDto {
|
export class CreateArticleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -49,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 {
|
||||||
@@ -92,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 {
|
||||||
@@ -108,8 +154,8 @@ export class FindArticlesDto {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ArticleStatus)
|
@IsString()
|
||||||
status?: ArticleStatus;
|
status?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -153,6 +199,30 @@ export class CreateLiveBlogDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class UpdateLiveBlogDto {
|
||||||
@@ -187,6 +257,30 @@ export class UpdateLiveBlogDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class CreateLiveBlogUpdateDto {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } 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';
|
||||||
@@ -10,6 +10,8 @@ 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>,
|
||||||
@@ -26,21 +28,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 });
|
||||||
@@ -107,6 +111,18 @@ export class ArticlesService {
|
|||||||
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;
|
||||||
|
return await this.articleRepository.save(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(id: string, status: ArticleStatus = ArticleStatus.PUBLISHED): Promise<Article> {
|
||||||
|
const article = await this.findOne(id);
|
||||||
|
article.status = status;
|
||||||
|
return await this.articleRepository.save(article);
|
||||||
|
}
|
||||||
|
|
||||||
async syncFromStrapi(
|
async syncFromStrapi(
|
||||||
strapiId: string,
|
strapiId: string,
|
||||||
data: Partial<CreateArticleDto>,
|
data: Partial<CreateArticleDto>,
|
||||||
@@ -125,4 +141,18 @@ export class ArticlesService {
|
|||||||
|
|
||||||
return await this.articleRepository.save(article);
|
return await this.articleRepository.save(article);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||||
|
const article = await this.articleRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.articleRepository.remove(article);
|
||||||
|
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,26 @@ export enum LiveBlogStatus {
|
|||||||
ARCHIVED = 'archived',
|
ARCHIVED = 'archived',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ImagePosition {
|
||||||
|
TOP = 'top',
|
||||||
|
LEFT = 'left',
|
||||||
|
RIGHT = 'right',
|
||||||
|
NONE = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImageSize {
|
||||||
|
SMALL = 'small',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
LARGE = 'large',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoPosition {
|
||||||
|
TOP = 'top',
|
||||||
|
INLINE = 'inline',
|
||||||
|
BOTTOM = 'bottom',
|
||||||
|
NONE = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('authors')
|
@Entity('authors')
|
||||||
export class Author {
|
export class Author {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -114,6 +134,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: '[]',
|
||||||
@@ -186,6 +230,33 @@ export class LiveBlog {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
categoryId: string;
|
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 })
|
@Column({ default: 0 })
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
CreateLiveBlogUpdateDto,
|
CreateLiveBlogUpdateDto,
|
||||||
UpdateLiveBlogUpdateDto,
|
UpdateLiveBlogUpdateDto,
|
||||||
} from './articles.dto';
|
} from './articles.dto';
|
||||||
|
import { LiveBlogStatus } from './entities';
|
||||||
|
|
||||||
@Controller('live-blogs')
|
@Controller('live-blogs')
|
||||||
export class LiveBlogController {
|
export class LiveBlogController {
|
||||||
@@ -121,6 +123,19 @@ export class LiveBlogController {
|
|||||||
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/archive')
|
||||||
|
archive(@Param('id') id: string) {
|
||||||
|
return this.liveBlogService.archive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/publish')
|
||||||
|
publish(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('status') status?: LiveBlogStatus,
|
||||||
|
) {
|
||||||
|
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
|
||||||
|
}
|
||||||
|
|
||||||
// SSE endpoint for real-time updates
|
// SSE endpoint for real-time updates
|
||||||
@Get(':id/stream')
|
@Get(':id/stream')
|
||||||
stream(
|
stream(
|
||||||
|
|||||||
@@ -116,13 +116,9 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
} else {
|
} else {
|
||||||
queryBuilder.where('liveBlog.status = :status', { status });
|
queryBuilder.where('liveBlog.status = :status', { status });
|
||||||
}
|
}
|
||||||
} else if (!isPinned) {
|
|
||||||
// Default to live blogs if no status specified AND not querying for pinned blogs
|
|
||||||
// Pinned blogs can be either live or ended
|
|
||||||
queryBuilder.where('liveBlog.status = :status', {
|
|
||||||
status: LiveBlogStatus.LIVE,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// If no status specified, return all live blogs (for admin dashboard)
|
||||||
|
// Note: Pinned blogs query should still work without status filter
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
queryBuilder.andWhere('category.slug = :category', { category });
|
queryBuilder.andWhere('category.slug = :category', { category });
|
||||||
@@ -262,6 +258,18 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
await this.liveBlogRepository.remove(liveBlog);
|
await this.liveBlogRepository.remove(liveBlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archive(id: string): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.findOne(id);
|
||||||
|
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||||
|
return await this.liveBlogRepository.save(liveBlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(id: string, status: LiveBlogStatus = LiveBlogStatus.DRAFT): Promise<LiveBlog> {
|
||||||
|
const liveBlog = await this.findOne(id);
|
||||||
|
liveBlog.status = status;
|
||||||
|
return await this.liveBlogRepository.save(liveBlog);
|
||||||
|
}
|
||||||
|
|
||||||
// Live Blog Update CRUD operations
|
// Live Blog Update CRUD operations
|
||||||
async createUpdate(
|
async createUpdate(
|
||||||
dto: CreateLiveBlogUpdateDto,
|
dto: CreateLiveBlogUpdateDto,
|
||||||
@@ -380,7 +388,7 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
const keepAliveInterval = setInterval(() => {
|
const keepAliveInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
response.write(`: keep-alive\n\n`);
|
response.write(`: keep-alive\n\n`);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Client disconnected, stop sending keep-alive
|
// Client disconnected, stop sending keep-alive
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
@@ -442,6 +450,22 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
return await this.liveBlogRepository.save(liveBlog);
|
return await this.liveBlogRepository.save(liveBlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Utility methods
|
||||||
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
|
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
|
||||||
const since = new Date();
|
const since = new Date();
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { Controller, Post, Body, Logger } from '@nestjs/common';
|
|||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
|
|
||||||
interface WebhookBody {
|
interface WebhookBody {
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish';
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish';
|
||||||
model: string;
|
model: string;
|
||||||
entry: {
|
entry: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -91,6 +96,7 @@ export class StrapiController {
|
|||||||
'entry.update',
|
'entry.update',
|
||||||
'entry.delete',
|
'entry.delete',
|
||||||
'entry.publish',
|
'entry.publish',
|
||||||
|
'entry.unpublish',
|
||||||
];
|
];
|
||||||
if (!validEvents.includes(event)) {
|
if (!validEvents.includes(event)) {
|
||||||
this.logger.warn(`Invalid event type: ${event}`);
|
this.logger.warn(`Invalid event type: ${event}`);
|
||||||
@@ -102,7 +108,8 @@ export class StrapiController {
|
|||||||
| 'entry.create'
|
| 'entry.create'
|
||||||
| 'entry.update'
|
| 'entry.update'
|
||||||
| 'entry.delete'
|
| 'entry.delete'
|
||||||
| 'entry.publish',
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
{ documentId, model },
|
{ documentId, model },
|
||||||
);
|
);
|
||||||
return { message: 'Webhook processed successfully' };
|
return { message: 'Webhook processed successfully' };
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { lastValueFrom } from 'rxjs';
|
|||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import { LiveBlogService } from './live-blog.service';
|
import { LiveBlogService } from './live-blog.service';
|
||||||
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
||||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
interface StrapiArticle {
|
interface StrapiArticle {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,6 +23,13 @@ interface StrapiArticle {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
img?: any;
|
||||||
|
media?: any[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrapiLiveBlog {
|
interface StrapiLiveBlog {
|
||||||
@@ -29,6 +42,13 @@ interface StrapiLiveBlog {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
img?: any;
|
||||||
|
media?: any[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrapiResponse<T> {
|
interface StrapiResponse<T> {
|
||||||
@@ -71,13 +91,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(),
|
||||||
},
|
},
|
||||||
@@ -88,6 +162,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,
|
||||||
@@ -97,6 +173,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(
|
||||||
@@ -115,13 +199,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(),
|
||||||
},
|
},
|
||||||
@@ -129,23 +223,70 @@ 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(
|
||||||
} catch (error) {
|
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||||
|
if (event === 'entry.unpublish' && error.response?.status === 404) {
|
||||||
|
this.logger.log(
|
||||||
|
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to update the article status to draft directly
|
||||||
|
try {
|
||||||
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
|
status: ArticleStatus.DRAFT,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.articlesService.syncFromStrapi(strapiId, articleData);
|
||||||
|
this.logger.log(
|
||||||
|
`Marked article ${strapiId} as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
} catch (syncError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to mark article ${strapiId} as draft:`,
|
||||||
|
syncError,
|
||||||
|
);
|
||||||
|
throw syncError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to sync article ${strapiId} from Strapi`,
|
`Failed to sync article ${strapiId} from Strapi`,
|
||||||
error,
|
error,
|
||||||
@@ -153,6 +294,7 @@ export class StrapiService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async syncLiveBlogs(): Promise<void> {
|
async syncLiveBlogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -160,7 +302,7 @@ export class StrapiService {
|
|||||||
|
|
||||||
const response = await lastValueFrom(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
||||||
`${this.strapiUrl}/api/live-blogs`,
|
`${this.strapiUrl}/api/live-blogs?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -171,11 +313,21 @@ export class StrapiService {
|
|||||||
let syncedCount = 0;
|
let syncedCount = 0;
|
||||||
|
|
||||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||||
|
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||||
|
|
||||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
title: strapiLiveBlog.title,
|
title: strapiLiveBlog.title,
|
||||||
description: strapiLiveBlog.description,
|
description: strapiLiveBlog.description,
|
||||||
slug: strapiLiveBlog.slug,
|
slug: strapiLiveBlog.slug,
|
||||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
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(
|
await this.liveBlogService.syncFromStrapi(
|
||||||
@@ -194,13 +346,23 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSingleLiveBlog(strapiId: string): Promise<void> {
|
async syncSingleLiveBlog(
|
||||||
|
strapiId: string,
|
||||||
|
event?:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}`);
|
this.logger.log(
|
||||||
|
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
|
||||||
|
);
|
||||||
|
|
||||||
const response = await lastValueFrom(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -208,19 +370,64 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const strapiLiveBlog = response.data.data;
|
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> = {
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
title: strapiLiveBlog.title,
|
title: strapiLiveBlog.title,
|
||||||
description: strapiLiveBlog.description,
|
description: strapiLiveBlog.description,
|
||||||
slug: strapiLiveBlog.slug,
|
slug: strapiLiveBlog.slug,
|
||||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
status,
|
||||||
|
featuredImage: imageUrl,
|
||||||
|
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
||||||
|
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||||
|
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||||
|
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
|
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.liveBlogService.syncFromStrapi(
|
await this.liveBlogService.syncFromStrapi(
|
||||||
strapiLiveBlog.documentId,
|
strapiLiveBlog.documentId,
|
||||||
liveBlogData,
|
liveBlogData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title}`);
|
this.logger.log(
|
||||||
} catch (error) {
|
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||||
|
if (event === 'entry.unpublish' && error.response?.status === 404) {
|
||||||
|
this.logger.log(
|
||||||
|
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to update the live blog status to draft directly
|
||||||
|
try {
|
||||||
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
|
status: LiveBlogStatus.DRAFT,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
|
||||||
|
this.logger.log(
|
||||||
|
`Marked live blog ${strapiId} as draft based on unpublish event`,
|
||||||
|
);
|
||||||
|
} catch (syncError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to mark live blog ${strapiId} as draft:`,
|
||||||
|
syncError,
|
||||||
|
);
|
||||||
|
throw syncError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to sync live blog ${strapiId} from Strapi`,
|
`Failed to sync live blog ${strapiId} from Strapi`,
|
||||||
error,
|
error,
|
||||||
@@ -228,6 +435,7 @@ export class StrapiService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
|
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -245,7 +453,12 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleWebhook(
|
async handleWebhook(
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish',
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
data: { documentId: string; model?: string },
|
data: { documentId: string; model?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -253,15 +466,25 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (event === 'entry.delete') {
|
if (event === 'entry.delete') {
|
||||||
this.logger.log(`Handling delete for document: ${data.documentId}`);
|
this.logger.log(
|
||||||
|
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.model === 'article') {
|
||||||
|
await this.articlesService.removeByStrapiId(data.documentId);
|
||||||
|
} else if (data.model === 'live-blog') {
|
||||||
|
await this.liveBlogService.removeByStrapiId(data.documentId);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to appropriate sync method based on model
|
// Route to appropriate sync method based on model
|
||||||
if (data.model === 'article') {
|
if (data.model === 'article') {
|
||||||
await this.syncSingleArticle(data.documentId);
|
await this.syncSingleArticle(data.documentId, event);
|
||||||
} else if (data.model === 'live-blog') {
|
} else if (data.model === 'live-blog') {
|
||||||
await this.syncSingleLiveBlog(data.documentId);
|
await this.syncSingleLiveBlog(data.documentId, event);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,30 @@
|
|||||||
"videos",
|
"videos",
|
||||||
"audios"
|
"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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
@@ -446,6 +446,12 @@ 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'>;
|
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<
|
||||||
@@ -462,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<''>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-18
@@ -8,6 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: placebo_user
|
POSTGRES_USER: placebo_user
|
||||||
POSTGRES_PASSWORD: placebo_password
|
POSTGRES_PASSWORD: placebo_password
|
||||||
|
POSTGRES_DB: placebo_backend_db
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_dev:/var/lib/postgresql/data
|
- postgres_data_dev:/var/lib/postgresql/data
|
||||||
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
|
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
|
||||||
@@ -22,21 +23,11 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: placebo-backend-dev
|
container_name: placebo-backend-dev
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env # Use the .env file for configuration
|
||||||
environment:
|
environment:
|
||||||
|
# Only override if needed, most config is in .env file
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USERNAME: placebo_user
|
|
||||||
DATABASE_PASSWORD: placebo_password
|
|
||||||
DATABASE_NAME: placebo_backend_db
|
|
||||||
DATABASE_SYNCHRONIZE: "true"
|
|
||||||
DATABASE_LOGGING: "true"
|
|
||||||
JWT_SECRET: dev-jwt-secret
|
|
||||||
JWT_EXPIRATION: 3600
|
|
||||||
CORS_ORIGIN: http://localhost:5173
|
|
||||||
PORT: 3000
|
|
||||||
STRAPI_URL: http://cms:1337
|
|
||||||
STRAPI_API_TOKEN: 578d628f62df967ff95f95bedb205b5d10bbf792340519c8c467d6473208e16b3918151a97b49fa2285a53df0ec8e340a9ca555b01a654bd22152847840e6a368ee626a6f1338ce2f23790c171013b263ec80fbaf116e2b459d3663b234d08855fd0eb631991ed15bb94f7dbb0b80f190352965c72c7fd327c73629ceff38fbb
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,8 +36,8 @@ services:
|
|||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/package.json:/app/package.json
|
- ./backend/package.json:/app/package.json
|
||||||
- ./backend/package-lock.json:/app/package-lock.json
|
- ./backend/package-lock.json:/app/package-lock.json
|
||||||
- ./backend/.env:/app/.env
|
- ./backend/.env:/app/.env # Mount .env file into container
|
||||||
command: npm run start:dev
|
command: npm run dev:docker
|
||||||
networks:
|
networks:
|
||||||
- placebo-network-dev
|
- placebo-network-dev
|
||||||
|
|
||||||
@@ -56,10 +47,10 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: placebo-frontend-dev
|
container_name: placebo-frontend-dev
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env # Use the .env file for configuration
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
VITE_API_URL: http://localhost:3000/api/v1
|
|
||||||
VITE_CMS_URL: http://localhost:1337
|
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -75,7 +66,7 @@ services:
|
|||||||
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
|
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
|
||||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
||||||
- ./frontend/.env:/app/.env
|
- ./frontend/.env:/app/.env
|
||||||
command: npm run dev
|
command: npm run dev:docker
|
||||||
networks:
|
networks:
|
||||||
- placebo-network-dev
|
- placebo-network-dev
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+1471
-22
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,10 @@
|
|||||||
"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-label": "^2.1.8",
|
||||||
@@ -25,7 +28,9 @@
|
|||||||
"@tanstack/react-router": "^1.144.0",
|
"@tanstack/react-router": "^1.144.0",
|
||||||
"date-fns": "^4.1.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",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useLiveBlogs } from '@/queries/live-blogs';
|
import { useLiveBlogs } from '@/queries/live-blogs';
|
||||||
import { useArticles } from '@/queries/articles';
|
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles';
|
||||||
|
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -8,12 +10,98 @@ import { format } from 'date-fns';
|
|||||||
import { mk } from 'date-fns/locale';
|
import { mk } from 'date-fns/locale';
|
||||||
|
|
||||||
export function AdminDashboardComponent() {
|
export function AdminDashboardComponent() {
|
||||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 });
|
// State for confirmation dialog and filters
|
||||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 });
|
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 liveBlogs = liveBlogsData?.data || [];
|
||||||
const articles = articlesData?.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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'published':
|
case 'published':
|
||||||
@@ -50,6 +138,12 @@ export function AdminDashboardComponent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={showArchived ? "default" : "outline"}
|
||||||
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
|
>
|
||||||
|
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||||
|
</Button>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -64,9 +158,9 @@ export function AdminDashboardComponent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span>Live блогови</span>
|
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||||||
<Badge variant="outline" className="ml-2">
|
<Badge variant="outline" className="ml-2">
|
||||||
{liveBlogs.length || 0}
|
{filteredLiveBlogs.length || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -79,16 +173,20 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
<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>
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
</div>
|
</div>
|
||||||
) : liveBlogs.length === 0 ? (
|
) : filteredLiveBlogs.length === 0 ? (
|
||||||
<div className="text-center py-8 border rounded-lg">
|
<div className="text-center py-8 border rounded-lg">
|
||||||
<p className="text-muted-foreground">Нема live блогови</p>
|
<p className="text-muted-foreground">
|
||||||
|
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||||
|
</p>
|
||||||
|
{!showArchived && (
|
||||||
<Button asChild variant="outline" className="mt-4">
|
<Button asChild variant="outline" className="mt-4">
|
||||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{liveBlogs.map((blog) => (
|
{filteredLiveBlogs.map((blog) => (
|
||||||
<div
|
<div
|
||||||
key={blog.id}
|
key={blog.id}
|
||||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
@@ -97,7 +195,8 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/live-blogs/${blog.slug}` as any}
|
to="/admin/live-blogs/$slug"
|
||||||
|
params={{ slug: blog.slug }}
|
||||||
className="font-medium hover:text-primary hover:underline"
|
className="font-medium hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{blog.title}
|
{blog.title}
|
||||||
@@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link to={`/admin/live-blogs/${blog.slug}` as any}>Уреди</Link>
|
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="ghost">
|
<Button asChild size="sm" variant="ghost">
|
||||||
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank">
|
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
|
||||||
Преглед
|
Преглед
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,9 +269,9 @@ export function AdminDashboardComponent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span>Написи</span>
|
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||||||
<Badge variant="outline" className="ml-2">
|
<Badge variant="outline" className="ml-2">
|
||||||
{articles.length || 0}
|
{filteredArticles.length || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -158,16 +284,20 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
<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>
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
</div>
|
</div>
|
||||||
) : articles.length === 0 ? (
|
) : filteredArticles.length === 0 ? (
|
||||||
<div className="text-center py-8 border rounded-lg">
|
<div className="text-center py-8 border rounded-lg">
|
||||||
<p className="text-muted-foreground">Нема написи</p>
|
<p className="text-muted-foreground">
|
||||||
|
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||||
|
</p>
|
||||||
|
{!showArchived && (
|
||||||
<Button asChild variant="outline" className="mt-4">
|
<Button asChild variant="outline" className="mt-4">
|
||||||
<Link to="/">Креирај нов напис</Link>
|
<Link to="/">Креирај нов напис</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{articles.map((article) => (
|
{filteredArticles.map((article) => (
|
||||||
<div
|
<div
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
@@ -176,7 +306,8 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Link
|
<Link
|
||||||
to={`/articles/${article.id}` as any}
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
className="font-medium hover:text-primary hover:underline"
|
className="font-medium hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{article.title}
|
{article.title}
|
||||||
@@ -202,13 +333,40 @@ export function AdminDashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link to={`/articles/${article.id}` as any}>Уреди</Link>
|
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="ghost">
|
<Button asChild size="sm" variant="ghost">
|
||||||
<Link to={`/articles/${article.id}` as any} target="_blank">
|
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||||
Преглед
|
Преглед
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +377,8 @@ export function AdminDashboardComponent() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats - Only show when not viewing archived items */}
|
||||||
|
{!showArchived && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
@@ -255,6 +414,37 @@ export function AdminDashboardComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import * as api from '@/lib/api'
|
import * as api from '@/lib/api'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||||
|
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||||
|
|
||||||
export function ArticleDetailComponent({ id }: { id: string }) {
|
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@@ -65,16 +69,101 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.featuredImage && (
|
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||||
|
<div className={`relative mb-4 ${
|
||||||
|
data.imagePosition === 'top'
|
||||||
|
? 'w-full mb-8'
|
||||||
|
: data.imagePosition === 'left'
|
||||||
|
? 'float-none md:float-left mr-0 md:mr-6'
|
||||||
|
: 'float-none md:float-right ml-0 md:ml-6'
|
||||||
|
}`}>
|
||||||
<img
|
<img
|
||||||
src={data.featuredImage}
|
src={data.featuredImage}
|
||||||
alt={data.title}
|
alt={data.title}
|
||||||
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
|
className={`rounded-xl object-cover ${
|
||||||
|
data.imagePosition === 'top'
|
||||||
|
? data.imageSize === 'small'
|
||||||
|
? 'h-32'
|
||||||
|
: data.imageSize === 'medium'
|
||||||
|
? 'h-48'
|
||||||
|
: 'h-64 md:h-96'
|
||||||
|
: data.imageSize === 'small'
|
||||||
|
? 'w-full md:w-48 h-32'
|
||||||
|
: data.imageSize === 'medium'
|
||||||
|
? 'w-full md:w-64 h-48'
|
||||||
|
: 'w-full md:w-96 h-64'
|
||||||
|
}`}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Failed to load image:', data.featuredImage, e);
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
// Show fallback
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) {
|
||||||
|
fallback.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
>
|
||||||
|
<span>Image not available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video rendering */}
|
||||||
|
{data.videoUrl && data.videoPosition !== 'none' && (
|
||||||
|
<div className={getVideoPositionClasses(data.videoPosition)}>
|
||||||
|
<YouTubeEmbed
|
||||||
|
url={data.videoUrl}
|
||||||
|
title={data.title}
|
||||||
|
caption={data.videoCaption}
|
||||||
|
autoplay={false}
|
||||||
|
controls={true}
|
||||||
|
modestbranding={true}
|
||||||
|
showRelated={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prose prose-slate max-w-none">
|
<div className="prose prose-slate max-w-none">
|
||||||
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
|
<div className="text-lg leading-relaxed mb-6">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
img: (props) => (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
className="max-w-full h-auto rounded-lg my-4"
|
||||||
|
alt={props.alt || 'Article image'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: (props) => {
|
||||||
|
// Check if the link is a YouTube URL
|
||||||
|
const videoId = extractYouTubeVideoId(props.href || '');
|
||||||
|
if (videoId) {
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<YouTubeEmbed
|
||||||
|
url={props.href || ''}
|
||||||
|
title={props.title || 'YouTube video'}
|
||||||
|
autoplay={false}
|
||||||
|
controls={true}
|
||||||
|
modestbranding={true}
|
||||||
|
showRelated={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Regular link
|
||||||
|
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,14 +124,14 @@ export function useLiveBlogStream(
|
|||||||
setConnectionError('Connection to live blog lost');
|
setConnectionError('Connection to live blog lost');
|
||||||
|
|
||||||
// Attempt reconnection if enabled and within limits
|
// Attempt reconnection if enabled and within limits
|
||||||
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < optionsRef.current.maxReconnectAttempts) {
|
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 10)) {
|
||||||
const nextAttempt = reconnectAttemptsRef.current + 1;
|
const nextAttempt = reconnectAttemptsRef.current + 1;
|
||||||
setReconnectAttempts(nextAttempt);
|
setReconnectAttempts(nextAttempt);
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`);
|
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
|
||||||
createConnection();
|
createConnection();
|
||||||
}, optionsRef.current.reconnectInterval);
|
}, optionsRef.current.reconnectInterval || 3000);
|
||||||
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) {
|
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
|
||||||
setConnectionError('Failed to reconnect after multiple attempts');
|
setConnectionError('Failed to reconnect after multiple attempts');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+130
-2
@@ -1,5 +1,9 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('API_BASE_URL:', API_BASE_URL);
|
||||||
|
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,6 +11,11 @@ export interface Article {
|
|||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
featuredImage: string;
|
featuredImage: string;
|
||||||
|
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||||
|
imageSize: 'small' | 'medium' | 'large';
|
||||||
|
videoUrl: string;
|
||||||
|
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||||
|
videoCaption: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: 'draft' | 'published' | 'archived';
|
status: 'draft' | 'published' | 'archived';
|
||||||
views: number;
|
views: number;
|
||||||
@@ -41,12 +50,48 @@ export interface FindArticlesParams {
|
|||||||
category?: string;
|
category?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
status?: 'draft' | 'published' | 'archived';
|
status?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateArticleDto {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
excerpt?: string;
|
||||||
|
slug?: string;
|
||||||
|
featuredImage?: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
strapiId?: string;
|
||||||
|
authorId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||||
|
imageSize?: 'small' | 'medium' | 'large';
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateArticleDto {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
slug?: string;
|
||||||
|
featuredImage?: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
strapiId?: string;
|
||||||
|
authorId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||||
|
imageSize?: 'small' | 'medium' | 'large';
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
|
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
|
||||||
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
|
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
@@ -93,6 +138,63 @@ export async function fetchArticleById(id: string): Promise<Article> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/articles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create article');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update article');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteArticle(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveArticle(id: string): Promise<Article> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to archive article');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to publish article');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Live Blog Types
|
// Live Blog Types
|
||||||
export interface LiveBlogUpdate {
|
export interface LiveBlogUpdate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -122,6 +224,12 @@ export interface LiveBlog {
|
|||||||
strapiId: string | null;
|
strapiId: string | null;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
featuredImage: string;
|
||||||
|
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||||
|
imageSize: 'small' | 'medium' | 'large';
|
||||||
|
videoUrl: string;
|
||||||
|
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||||
|
videoCaption: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
author?: {
|
author?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -158,7 +266,7 @@ export interface LiveBlogUpdatesResponse {
|
|||||||
export interface FindLiveBlogsParams {
|
export interface FindLiveBlogsParams {
|
||||||
category?: string;
|
category?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
status?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -357,3 +465,23 @@ export async function deleteLiveBlog(id: string): Promise<void> {
|
|||||||
throw new Error('Failed to delete live blog');
|
throw new Error('Failed to delete live blog');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to archive live blog');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to publish live blog');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Video utilities for handling YouTube embeds and video URLs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VideoPosition = 'top' | 'inline' | 'bottom' | 'none';
|
||||||
|
export type VideoPlatform = 'youtube' | 'vimeo' | 'dailymotion' | 'twitch' | 'uploaded' | 'external';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract YouTube video ID from various URL formats
|
||||||
|
* Supported formats:
|
||||||
|
* - https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
* - https://youtu.be/VIDEO_ID
|
||||||
|
* - https://www.youtube.com/embed/VIDEO_ID
|
||||||
|
* - https://www.youtube.com/v/VIDEO_ID
|
||||||
|
* - https://www.youtube.com/shorts/VIDEO_ID
|
||||||
|
*/
|
||||||
|
export function extractYouTubeVideoId(url: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
// Standard watch URL
|
||||||
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// Just the video ID
|
||||||
|
/^([a-zA-Z0-9_-]{11})$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a valid YouTube URL
|
||||||
|
*/
|
||||||
|
export function isValidYouTubeUrl(url: string): boolean {
|
||||||
|
return extractYouTubeVideoId(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get YouTube thumbnail URL for a video ID
|
||||||
|
* Quality options: default, mq (medium), hq (high), sd (standard), maxres (maximum resolution)
|
||||||
|
*/
|
||||||
|
export function getYouTubeThumbnail(
|
||||||
|
videoId: string,
|
||||||
|
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
|
||||||
|
): string {
|
||||||
|
const qualityMap = {
|
||||||
|
default: 'default.jpg',
|
||||||
|
mq: 'mqdefault.jpg',
|
||||||
|
hq: 'hqdefault.jpg',
|
||||||
|
sd: 'sddefault.jpg',
|
||||||
|
maxres: 'maxresdefault.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect video platform from URL
|
||||||
|
*/
|
||||||
|
export function detectVideoPlatform(url: string): VideoPlatform {
|
||||||
|
if (!url) return 'external';
|
||||||
|
|
||||||
|
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||||
|
if (url.includes('vimeo.com')) return 'vimeo';
|
||||||
|
if (url.includes('dailymotion.com')) return 'dailymotion';
|
||||||
|
if (url.includes('twitch.tv')) return 'twitch';
|
||||||
|
if (url.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)$/i)) return 'uploaded';
|
||||||
|
|
||||||
|
return 'external';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate YouTube embed URL with privacy-enhanced mode and parameters
|
||||||
|
*/
|
||||||
|
export function generateYouTubeEmbedUrl(
|
||||||
|
videoId: string,
|
||||||
|
options: {
|
||||||
|
autoplay?: boolean;
|
||||||
|
controls?: boolean;
|
||||||
|
modestbranding?: boolean;
|
||||||
|
rel?: boolean;
|
||||||
|
playsinline?: boolean;
|
||||||
|
start?: number;
|
||||||
|
end?: number;
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
autoplay = false,
|
||||||
|
controls = true,
|
||||||
|
modestbranding = true,
|
||||||
|
rel = false,
|
||||||
|
playsinline = true,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
autoplay: autoplay ? '1' : '0',
|
||||||
|
controls: controls ? '1' : '0',
|
||||||
|
modestbranding: modestbranding ? '1' : '0',
|
||||||
|
rel: rel ? '1' : '0',
|
||||||
|
playsinline: playsinline ? '1' : '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (start !== undefined) params.set('start', start.toString());
|
||||||
|
if (end !== undefined) params.set('end', end.toString());
|
||||||
|
|
||||||
|
// Use privacy-enhanced mode (youtube-nocookie.com)
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSS classes for video positioning
|
||||||
|
*/
|
||||||
|
export function getVideoPositionClasses(position: VideoPosition): string {
|
||||||
|
switch (position) {
|
||||||
|
case 'top':
|
||||||
|
return 'mb-8';
|
||||||
|
case 'inline':
|
||||||
|
return 'my-6';
|
||||||
|
case 'bottom':
|
||||||
|
return 'mt-8';
|
||||||
|
case 'none':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return 'my-6';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate video URL
|
||||||
|
*/
|
||||||
|
export function validateVideoUrl(url: string): { isValid: boolean; platform: VideoPlatform; error?: string } {
|
||||||
|
if (!url) {
|
||||||
|
return { isValid: false, platform: 'external', error: 'Video URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
return { isValid: false, platform: 'external', error: 'Invalid URL format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = detectVideoPlatform(url);
|
||||||
|
|
||||||
|
if (platform === 'youtube' && !isValidYouTubeUrl(url)) {
|
||||||
|
return { isValid: false, platform: 'youtube', error: 'Invalid YouTube URL' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, platform };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as api from '../lib/api';
|
import * as api from '../lib/api';
|
||||||
|
|
||||||
export function useArticles(params: api.FindArticlesParams = {}) {
|
export function useArticles(params: api.FindArticlesParams = {}) {
|
||||||
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateArticle() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createArticle,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateArticle() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
|
||||||
|
api.updateArticle(id, dto),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteArticle() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.deleteArticle,
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
// Also invalidate any specific article queries
|
||||||
|
queryClient.removeQueries({ queryKey: ['article', variables] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveArticle() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.archiveArticle,
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', variables] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublishArticle() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
|
||||||
|
api.publishArticle(id, status),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -156,3 +156,30 @@ export function useDeleteLiveBlog() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useArchiveLiveBlog() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.archiveLiveBlog(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate all live blogs queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublishLiveBlog() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
|
||||||
|
api.publishLiveBlog(id, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate all live blogs queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+29294
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "placebo-mk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Placebo.mk - Macedonian news site with sarcastic tone",
|
||||||
|
"scripts": {
|
||||||
|
"docker:up": "docker-compose -f docker-compose.dev.yml up",
|
||||||
|
"docker:down": "docker-compose -f docker-compose.dev.yml down",
|
||||||
|
"docker:build": "docker-compose -f docker-compose.dev.yml build",
|
||||||
|
"docker:logs": "docker-compose -f docker-compose.dev.yml logs -f",
|
||||||
|
"docker:restart": "docker-compose -f docker-compose.dev.yml restart",
|
||||||
|
"docker:ps": "docker-compose -f docker-compose.dev.yml ps",
|
||||||
|
"dev:docker": "npm run docker:up",
|
||||||
|
"dev:local": "echo 'Starting local development...' && concurrently \"npm run dev:backend:local\" \"npm run dev:frontend:local\"",
|
||||||
|
"dev:backend:local": "cd backend && npm run dev:local",
|
||||||
|
"dev:frontend:local": "cd frontend && npm run dev:local",
|
||||||
|
"dev:backend:docker": "cd backend && npm run dev:docker",
|
||||||
|
"dev:frontend:docker": "cd frontend && npm run dev:docker",
|
||||||
|
"reset:env": "cd backend && npm run dev:reset-env && cd ../frontend && npm run dev:reset-env",
|
||||||
|
"lint": "concurrently \"cd backend && npm run lint\" \"cd frontend && npm run lint\"",
|
||||||
|
"lint:fix": "concurrently \"cd backend && npm run lint:fix\" \"cd frontend && npm run lint:fix\"",
|
||||||
|
"type-check": "concurrently \"cd backend && npm run type-check\" \"cd frontend && npm run type-check\"",
|
||||||
|
"db:backup": "./scripts/db-backup.sh",
|
||||||
|
"db:restore": "./scripts/db-restore.sh",
|
||||||
|
"db:reset": "./scripts/db-reset.sh"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend",
|
||||||
|
"cms/cms"
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Database Backup Script
|
||||||
|
# Backs up PostgreSQL databases from Docker container
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "📦 Backing up PostgreSQL databases..."
|
||||||
|
|
||||||
|
# Backup backend database
|
||||||
|
echo "Backing up placebo_backend_db..."
|
||||||
|
docker exec placebo-postgres-dev pg_dump -U placebo_user -d placebo_backend_db > "$BACKUP_DIR/backend_db_$TIMESTAMP.sql"
|
||||||
|
|
||||||
|
# Backup CMS database
|
||||||
|
echo "Backing up placebo_cms_db..."
|
||||||
|
docker exec placebo-postgres-dev pg_dump -U placebo_user -d placebo_cms_db > "$BACKUP_DIR/cms_db_$TIMESTAMP.sql"
|
||||||
|
|
||||||
|
echo "✅ Backups created in $BACKUP_DIR/"
|
||||||
|
echo "Backend: backend_db_$TIMESTAMP.sql"
|
||||||
|
echo "CMS: cms_db_$TIMESTAMP.sql"
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Database Reset Script
|
||||||
|
# Resets PostgreSQL databases to initial state
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "⚠️ WARNING: This will reset ALL databases!"
|
||||||
|
read -p "Are you sure? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔄 Resetting PostgreSQL databases..."
|
||||||
|
|
||||||
|
# Stop backend and CMS to avoid connection issues
|
||||||
|
echo "Stopping backend and CMS containers..."
|
||||||
|
docker stop placebo-backend-dev placebo-cms-dev 2>/dev/null || true
|
||||||
|
|
||||||
|
# Drop and recreate databases
|
||||||
|
echo "Resetting databases..."
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS placebo_backend_db;" postgres
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS placebo_cms_db;" postgres
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE placebo_backend_db;" postgres
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE placebo_cms_db;" postgres
|
||||||
|
|
||||||
|
# Run initialization script
|
||||||
|
echo "Running initialization script..."
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -f /docker-entrypoint-initdb.d/init-postgres-dev.sql postgres
|
||||||
|
|
||||||
|
# Restart containers
|
||||||
|
echo "Restarting containers..."
|
||||||
|
docker start placebo-backend-dev placebo-cms-dev 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "✅ Databases reset successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Note: Backend and CMS will need to restart their services."
|
||||||
|
echo "For backend: docker restart placebo-backend-dev"
|
||||||
|
echo "For CMS: docker restart placebo-cms-dev"
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Database Restore Script
|
||||||
|
# Restores PostgreSQL databases from backup
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <backup_file> [database_name]"
|
||||||
|
echo "Example: $0 backups/backend_db_20250101_120000.sql"
|
||||||
|
echo "Example: $0 backups/backend_db_20250101_120000.sql placebo_backend_db"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_FILE="$1"
|
||||||
|
DATABASE="${2:-placebo_backend_db}"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_FILE" ]; then
|
||||||
|
echo "❌ Backup file not found: $BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔄 Restoring database $DATABASE from $BACKUP_FILE..."
|
||||||
|
|
||||||
|
# Drop and recreate database
|
||||||
|
echo "Recreating database..."
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "DROP DATABASE IF EXISTS $DATABASE;" postgres
|
||||||
|
docker exec placebo-postgres-dev psql -U placebo_user -c "CREATE DATABASE $DATABASE;" postgres
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
echo "Restoring data..."
|
||||||
|
docker exec -i placebo-postgres-dev psql -U placebo_user -d "$DATABASE" < "$BACKUP_FILE"
|
||||||
|
|
||||||
|
echo "✅ Database $DATABASE restored successfully!"
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Placebo.mk Setup Script
|
||||||
|
# This script sets up the development environment
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Setting up Placebo.mk development environment..."
|
||||||
|
|
||||||
|
# Check if Docker is installed
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker is not installed. Please install Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker Compose is installed
|
||||||
|
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose is not installed. Please install Docker Compose."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Node.js version
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "⚠️ Node.js is not installed. Local mode won't work, but Docker mode will."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
echo "📁 Creating directories..."
|
||||||
|
mkdir -p scripts
|
||||||
|
mkdir -p backend/uploads
|
||||||
|
mkdir -p cms/cms/public/uploads
|
||||||
|
|
||||||
|
# Set up environment files
|
||||||
|
echo "⚙️ Setting up environment files..."
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
if [ ! -f backend/.env ]; then
|
||||||
|
echo "Creating backend/.env from template..."
|
||||||
|
cp backend/.env.docker.template backend/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f backend/.env.local ]; then
|
||||||
|
echo "Creating backend/.env.local from template..."
|
||||||
|
cp backend/.env.local.template backend/.env.local
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
if [ ! -f frontend/.env ]; then
|
||||||
|
echo "Creating frontend/.env from template..."
|
||||||
|
cp frontend/.env.docker.template frontend/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f frontend/.env.local ]; then
|
||||||
|
echo "Creating frontend/.env.local from template..."
|
||||||
|
cp frontend/.env.local.template frontend/.env.local
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install root dependencies
|
||||||
|
echo "📦 Installing root dependencies..."
|
||||||
|
if command -v npm &> /dev/null; then
|
||||||
|
npm install
|
||||||
|
else
|
||||||
|
echo "⚠️ npm not found, skipping root dependencies"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build Docker images
|
||||||
|
echo "🐳 Building Docker images..."
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
docker compose -f docker-compose.dev.yml build
|
||||||
|
else
|
||||||
|
docker-compose -f docker-compose.dev.yml build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Start in Docker mode: npm run docker:up"
|
||||||
|
echo "2. Or start in local mode:"
|
||||||
|
echo " - First terminal: docker-compose -f docker-compose.dev.yml up postgres"
|
||||||
|
echo " - Second terminal: npm run dev:local"
|
||||||
|
echo ""
|
||||||
|
echo "For more details, see README-DEVELOPMENT.md"
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test environment configuration
|
||||||
|
|
||||||
|
echo "🧪 Testing environment configuration..."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Backend Environment ==="
|
||||||
|
echo "Current .env DATABASE_HOST:"
|
||||||
|
grep DATABASE_HOST backend/.env || echo "Not found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Backend .env.local DATABASE_HOST:"
|
||||||
|
grep DATABASE_HOST backend/.env.local || echo "Not found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Frontend Environment ==="
|
||||||
|
echo "Current .env VITE_API_URL:"
|
||||||
|
grep VITE_API_URL frontend/.env || echo "Not found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Frontend .env.local VITE_API_URL:"
|
||||||
|
grep VITE_API_URL frontend/.env.local || echo "Not found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Docker Compose ==="
|
||||||
|
echo "Checking Docker Compose configuration..."
|
||||||
|
if grep -q "env_file:" docker-compose.dev.yml; then
|
||||||
|
echo "✓ env_file directive is present"
|
||||||
|
else
|
||||||
|
echo "✗ env_file directive not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Environment test complete!"
|
||||||
|
echo ""
|
||||||
|
echo "To switch to local mode:"
|
||||||
|
echo " backend: npm run dev:local"
|
||||||
|
echo " frontend: npm run dev:local"
|
||||||
|
echo ""
|
||||||
|
echo "To reset to Docker mode:"
|
||||||
|
echo " npm run reset:env"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
1. role based auth [admin, contributor, user]
|
||||||
|
2. [ ] video
|
||||||
|
3. social media integration [telegram, whatsup, facebook, viber]
|
||||||
|
4. share with functionality
|
||||||
|
|
||||||
|
lets implement a role based auth
|
||||||
|
admin can create, edit and delete articles and liveblogs
|
||||||
|
admin can create contributors
|
||||||
|
contributors can create, edit and delete articles and liveblogs
|
||||||
|
user can react(like, dislike) to articles and write comments
|
||||||
Reference in new issue
Block a user