Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7378d37b36 | ||
|
|
9ca05f5ea1 | ||
|
|
89b8687431 | ||
|
|
3bc534489d | ||
|
|
6467e21019 | ||
|
|
28609a6492 |
No files matched your search
+2
-2
@@ -15,11 +15,11 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||
await app.listen(port, host);
|
||||
|
||||
|
||||
console.log(`Application is running on: http://${host}:${port}`);
|
||||
}
|
||||
void bootstrap();
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
UpdateArticleDto,
|
||||
FindArticlesDto,
|
||||
} from './articles.dto';
|
||||
import { ArticleStatus } from './entities';
|
||||
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
@@ -54,4 +56,14 @@ export class ArticlesController {
|
||||
remove(@Param('id') id: string) {
|
||||
return this.articlesService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
archive(@Param('id') id: string) {
|
||||
return this.articlesService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
publish(@Param('id') id: string, @Query('status') status?: ArticleStatus) {
|
||||
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
} from 'class-validator';
|
||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
||||
import {
|
||||
ArticleStatus,
|
||||
LiveBlogStatus,
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
} from './entities';
|
||||
|
||||
export class CreateArticleDto {
|
||||
@IsString()
|
||||
@@ -49,6 +55,26 @@ export class CreateArticleDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@@ -92,6 +118,26 @@ export class UpdateArticleDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class FindArticlesDto {
|
||||
@@ -108,8 +154,8 @@ export class FindArticlesDto {
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -153,6 +199,30 @@ export class CreateLiveBlogDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogDto {
|
||||
@@ -187,6 +257,30 @@ export class UpdateLiveBlogDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class CreateLiveBlogUpdateDto {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { Article, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Article, Author, Category]),
|
||||
forwardRef(() => StrapiModule),
|
||||
],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
exports: [ArticlesService],
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Article, ArticleStatus } from './entities';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import {
|
||||
CreateArticleDto,
|
||||
UpdateArticleDto,
|
||||
@@ -10,9 +17,13 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
private readonly logger = new Logger(ArticlesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Article)
|
||||
private readonly articleRepository: Repository<Article>,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateArticleDto): Promise<Article> {
|
||||
@@ -26,21 +37,23 @@ export class ArticlesService {
|
||||
async findAll(
|
||||
dto: FindArticlesDto,
|
||||
): Promise<{ data: Article[]; total: number }> {
|
||||
const {
|
||||
category,
|
||||
author,
|
||||
tag,
|
||||
status = ArticleStatus.PUBLISHED,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = dto;
|
||||
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
|
||||
|
||||
const queryBuilder = this.articleRepository
|
||||
.createQueryBuilder('article')
|
||||
.leftJoinAndSelect('article.author', 'author')
|
||||
.leftJoinAndSelect('article.category', 'category')
|
||||
.where('article.status = :status', { status });
|
||||
.leftJoinAndSelect('article.category', 'category');
|
||||
|
||||
// Handle status filter - can be single value or comma-separated list
|
||||
if (status) {
|
||||
if (typeof status === 'string' && status.includes(',')) {
|
||||
const statuses = status.split(',').map((s) => s.trim());
|
||||
queryBuilder.where('article.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
queryBuilder.where('article.status = :status', { status });
|
||||
}
|
||||
}
|
||||
// If no status specified, return all articles (for admin dashboard)
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('category.slug = :category', { category });
|
||||
@@ -98,31 +111,186 @@ export class ArticlesService {
|
||||
|
||||
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
const oldStatus = article.status;
|
||||
Object.assign(article, dto);
|
||||
return await this.articleRepository.save(article);
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if status changed and article has strapiId
|
||||
if (
|
||||
dto.status !== undefined &&
|
||||
dto.status !== oldStatus &&
|
||||
article.strapiId
|
||||
) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
dto.status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const article = await this.findOne(id);
|
||||
|
||||
// Delete from Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - we still want to delete from backend
|
||||
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.articleRepository.remove(article);
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
article.status = ArticleStatus.ARCHIVED;
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
ArticleStatus.ARCHIVED,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async publish(
|
||||
id: string,
|
||||
status: ArticleStatus = ArticleStatus.PUBLISHED,
|
||||
): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
article.status = status;
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async syncFromStrapi(
|
||||
strapiId: string,
|
||||
data: Partial<CreateArticleDto>,
|
||||
): Promise<Article> {
|
||||
let article = await this.articleRepository.findOne({ where: { strapiId } });
|
||||
// Use upsert to handle race conditions and ensure uniqueness
|
||||
try {
|
||||
// First try to find existing article by strapiId
|
||||
const article = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (article) {
|
||||
// Update existing article
|
||||
const currentStatus = article.status;
|
||||
Object.assign(article, data);
|
||||
|
||||
// Preserve archived status if article is already archived in our system
|
||||
// unless Strapi explicitly sends a different status
|
||||
if (
|
||||
currentStatus === ArticleStatus.ARCHIVED &&
|
||||
data.status !== ArticleStatus.ARCHIVED
|
||||
) {
|
||||
article.status = ArticleStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(article);
|
||||
} else {
|
||||
// Create new article
|
||||
const newArticle = this.articleRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || ArticleStatus.DRAFT,
|
||||
});
|
||||
|
||||
return await this.articleRepository.save(newArticle);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// If we get a unique constraint violation, try to find and update again
|
||||
// This handles race conditions where two syncs happen simultaneously
|
||||
const dbError = error as { code?: string; constraint?: string };
|
||||
if (
|
||||
dbError.code === '23505' &&
|
||||
dbError.constraint?.includes('strapiId')
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||
);
|
||||
|
||||
// Wait a bit and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const existingArticle = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (existingArticle) {
|
||||
const currentStatus = existingArticle.status;
|
||||
Object.assign(existingArticle, data);
|
||||
|
||||
if (
|
||||
currentStatus === ArticleStatus.ARCHIVED &&
|
||||
data.status !== ArticleStatus.ARCHIVED
|
||||
) {
|
||||
existingArticle.status = ArticleStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(existingArticle);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||
const article = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
article = this.articleRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || ArticleStatus.DRAFT,
|
||||
});
|
||||
} else {
|
||||
Object.assign(article, data);
|
||||
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(article);
|
||||
await this.articleRepository.remove(article);
|
||||
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,26 @@ export enum LiveBlogStatus {
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum ImagePosition {
|
||||
TOP = 'top',
|
||||
LEFT = 'left',
|
||||
RIGHT = 'right',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum ImageSize {
|
||||
SMALL = 'small',
|
||||
MEDIUM = 'medium',
|
||||
LARGE = 'large',
|
||||
}
|
||||
|
||||
export enum VideoPosition {
|
||||
TOP = 'top',
|
||||
INLINE = 'inline',
|
||||
BOTTOM = 'bottom',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
@Entity('authors')
|
||||
export class Author {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -114,6 +134,30 @@ export class Article {
|
||||
@Column({ default: '' })
|
||||
featuredImage: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'top',
|
||||
})
|
||||
imagePosition: ImagePosition;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'medium',
|
||||
})
|
||||
imageSize: ImageSize;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoUrl: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'inline',
|
||||
})
|
||||
videoPosition: VideoPosition;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoCaption: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: '[]',
|
||||
@@ -130,7 +174,7 @@ export class Article {
|
||||
@Column({ default: 0 })
|
||||
views: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column({ nullable: true, unique: true })
|
||||
strapiId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@@ -177,7 +221,7 @@ export class LiveBlog {
|
||||
@Column({ default: false })
|
||||
isPinned: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column({ nullable: true, unique: true })
|
||||
strapiId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@@ -186,6 +230,33 @@ export class LiveBlog {
|
||||
@Column({ nullable: true })
|
||||
categoryId: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
featuredImage: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'top',
|
||||
})
|
||||
imagePosition: ImagePosition;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'medium',
|
||||
})
|
||||
imageSize: ImageSize;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoUrl: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'inline',
|
||||
})
|
||||
videoPosition: VideoPosition;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoCaption: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
viewCount: number;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
CreateLiveBlogUpdateDto,
|
||||
UpdateLiveBlogUpdateDto,
|
||||
} from './articles.dto';
|
||||
import { LiveBlogStatus } from './entities';
|
||||
|
||||
@Controller('live-blogs')
|
||||
export class LiveBlogController {
|
||||
@@ -121,6 +123,16 @@ export class LiveBlogController {
|
||||
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
archive(@Param('id') id: string) {
|
||||
return this.liveBlogService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
|
||||
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
|
||||
}
|
||||
|
||||
// SSE endpoint for real-time updates
|
||||
@Get(':id/stream')
|
||||
stream(
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
import { LiveBlogController } from './live-blog.controller';
|
||||
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||
EventEmitterModule.forRoot(),
|
||||
forwardRef(() => StrapiModule),
|
||||
],
|
||||
controllers: [LiveBlogController],
|
||||
providers: [LiveBlogService],
|
||||
|
||||
@@ -3,12 +3,15 @@ import {
|
||||
NotFoundException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Response } from 'express';
|
||||
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import {
|
||||
CreateLiveBlogDto,
|
||||
UpdateLiveBlogDto,
|
||||
@@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit {
|
||||
@InjectRepository(LiveBlogUpdate)
|
||||
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -116,13 +121,9 @@ export class LiveBlogService implements OnModuleInit {
|
||||
} else {
|
||||
queryBuilder.where('liveBlog.status = :status', { status });
|
||||
}
|
||||
} else if (!isPinned) {
|
||||
// Default to live blogs if no status specified AND not querying for pinned blogs
|
||||
// Pinned blogs can be either live or ended
|
||||
queryBuilder.where('liveBlog.status = :status', {
|
||||
status: LiveBlogStatus.LIVE,
|
||||
});
|
||||
}
|
||||
// If no status specified, return all live blogs (for admin dashboard)
|
||||
// Note: Pinned blogs query should still work without status filter
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('category.slug = :category', { category });
|
||||
@@ -252,6 +253,22 @@ export class LiveBlogService implements OnModuleInit {
|
||||
blogId: id,
|
||||
status: dto.status,
|
||||
});
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
dto.status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedBlog;
|
||||
@@ -259,9 +276,74 @@ export class LiveBlogService implements OnModuleInit {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
|
||||
// Delete from Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - we still want to delete from backend
|
||||
this.logger.error(
|
||||
`Failed to delete live blog ${id} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.liveBlogRepository.remove(liveBlog);
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<LiveBlog> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
LiveBlogStatus.ARCHIVED,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedLiveBlog;
|
||||
}
|
||||
|
||||
async publish(
|
||||
id: string,
|
||||
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
|
||||
): Promise<LiveBlog> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
liveBlog.status = status;
|
||||
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedLiveBlog;
|
||||
}
|
||||
|
||||
// Live Blog Update CRUD operations
|
||||
async createUpdate(
|
||||
dto: CreateLiveBlogUpdateDto,
|
||||
@@ -380,7 +462,7 @@ export class LiveBlogService implements OnModuleInit {
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
try {
|
||||
response.write(`: keep-alive\n\n`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Client disconnected, stop sending keep-alive
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
@@ -425,21 +507,91 @@ export class LiveBlogService implements OnModuleInit {
|
||||
strapiId: string,
|
||||
data: Partial<CreateLiveBlogDto>,
|
||||
): Promise<LiveBlog> {
|
||||
let liveBlog = await this.liveBlogRepository.findOne({
|
||||
// Use upsert to handle race conditions and ensure uniqueness
|
||||
try {
|
||||
// First try to find existing live blog by strapiId
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (liveBlog) {
|
||||
// Update existing live blog
|
||||
const currentStatus = liveBlog.status;
|
||||
Object.assign(liveBlog, data);
|
||||
|
||||
// Preserve archived status if live blog is already archived in our system
|
||||
// unless Strapi explicitly sends a different status
|
||||
if (
|
||||
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||
data.status !== LiveBlogStatus.ARCHIVED
|
||||
) {
|
||||
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(liveBlog);
|
||||
} else {
|
||||
// Create new live blog
|
||||
const newLiveBlog = this.liveBlogRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || LiveBlogStatus.DRAFT,
|
||||
});
|
||||
|
||||
return await this.liveBlogRepository.save(newLiveBlog);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// If we get a unique constraint violation, try to find and update again
|
||||
// This handles race conditions where two syncs happen simultaneously
|
||||
const dbError = error as { code?: string; constraint?: string };
|
||||
if (
|
||||
dbError.code === '23505' &&
|
||||
dbError.constraint?.includes('strapiId')
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||
);
|
||||
|
||||
// Wait a bit and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const existingLiveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (existingLiveBlog) {
|
||||
const currentStatus = existingLiveBlog.status;
|
||||
Object.assign(existingLiveBlog, data);
|
||||
|
||||
if (
|
||||
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||
data.status !== LiveBlogStatus.ARCHIVED
|
||||
) {
|
||||
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(existingLiveBlog);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
liveBlog = this.liveBlogRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || LiveBlogStatus.DRAFT,
|
||||
});
|
||||
} else {
|
||||
Object.assign(liveBlog, data);
|
||||
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(liveBlog);
|
||||
await this.liveBlogRepository.remove(liveBlog);
|
||||
this.logger.log(
|
||||
`Successfully deleted live blog with strapiId: ${strapiId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import { StrapiService } from './strapi.service';
|
||||
|
||||
interface WebhookBody {
|
||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish';
|
||||
event:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish';
|
||||
model: string;
|
||||
entry: {
|
||||
documentId: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import { StrapiController } from './strapi.controller';
|
||||
@@ -6,7 +6,11 @@ import { ArticlesModule } from './articles.module';
|
||||
import { LiveBlogModule } from './live-blog.module';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ArticlesModule, LiveBlogModule],
|
||||
imports: [
|
||||
HttpModule,
|
||||
forwardRef(() => ArticlesModule),
|
||||
forwardRef(() => LiveBlogModule),
|
||||
],
|
||||
controllers: [StrapiController],
|
||||
providers: [StrapiService],
|
||||
exports: [StrapiService],
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
||||
import {
|
||||
ArticleStatus,
|
||||
LiveBlogStatus,
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
} from './entities';
|
||||
|
||||
interface StrapiImage {
|
||||
url: string;
|
||||
alternativeText?: string;
|
||||
caption?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
formats?: Record<string, StrapiImageFormat>;
|
||||
}
|
||||
|
||||
interface StrapiImageFormat {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface StrapiArticle {
|
||||
id: number;
|
||||
@@ -17,6 +38,13 @@ interface StrapiArticle {
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
img?: StrapiImage;
|
||||
media?: StrapiImage[];
|
||||
imagePosition?: string;
|
||||
imageSize?: string;
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
interface StrapiLiveBlog {
|
||||
@@ -29,6 +57,13 @@ interface StrapiLiveBlog {
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
img?: StrapiImage;
|
||||
media?: StrapiImage[];
|
||||
imagePosition?: string;
|
||||
imageSize?: string;
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
interface StrapiResponse<T> {
|
||||
@@ -52,7 +87,9 @@ export class StrapiService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
@Inject(forwardRef(() => ArticlesService))
|
||||
private readonly articlesService: ArticlesService,
|
||||
@Inject(forwardRef(() => LiveBlogService))
|
||||
private readonly liveBlogService: LiveBlogService,
|
||||
) {
|
||||
this.strapiUrl =
|
||||
@@ -71,13 +108,67 @@ export class StrapiService {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
|
||||
// Try to get image from img field first (single image)
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (strapiArticle.img?.url) {
|
||||
imageUrl = strapiArticle.img.url;
|
||||
} else if (strapiArticle.media?.[0]?.url) {
|
||||
// Try to get first image from media field (multiple images)
|
||||
imageUrl = strapiArticle.media[0].url;
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If URL is relative, prepend Strapi base URL
|
||||
if (imageUrl.startsWith('/')) {
|
||||
// Convert Docker service URL to localhost for frontend access
|
||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
||||
return `${frontendStrapiUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
private extractLiveBlogImageUrl(
|
||||
strapiLiveBlog: StrapiLiveBlog,
|
||||
): string | undefined {
|
||||
// Try to get image from img field first (single image)
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (strapiLiveBlog.img?.url) {
|
||||
imageUrl = strapiLiveBlog.img.url;
|
||||
} else if (strapiLiveBlog.media?.[0]?.url) {
|
||||
// Try to get first image from media field (multiple images)
|
||||
imageUrl = strapiLiveBlog.media[0].url;
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If URL is relative, prepend Strapi base URL
|
||||
if (imageUrl.startsWith('/')) {
|
||||
// Convert Docker service URL to localhost for frontend access
|
||||
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
|
||||
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
|
||||
return `${frontendStrapiUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
async syncArticles(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('Starting articles sync from Strapi...');
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
||||
`${this.strapiUrl}/api/articles`,
|
||||
`${this.strapiUrl}/api/articles?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@@ -88,6 +179,8 @@ export class StrapiService {
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const strapiArticle of strapiArticles) {
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
@@ -97,6 +190,14 @@ export class StrapiService {
|
||||
? ArticleStatus.PUBLISHED
|
||||
: ArticleStatus.DRAFT,
|
||||
tags: [],
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiArticle.imagePosition ||
|
||||
'top') as ImagePosition,
|
||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiArticle.videoUrl || '',
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
@@ -117,14 +218,21 @@ export class StrapiService {
|
||||
|
||||
async syncSingleArticle(
|
||||
strapiId: string,
|
||||
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
|
||||
event?:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Syncing single article from Strapi: ${strapiId}, event: ${event}`);
|
||||
this.logger.log(
|
||||
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
|
||||
);
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
||||
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@@ -132,7 +240,7 @@ export class StrapiService {
|
||||
);
|
||||
|
||||
const strapiArticle = response.data.data;
|
||||
|
||||
|
||||
// Determine status based on publishedAt and event type
|
||||
let status: ArticleStatus;
|
||||
if (event === 'entry.unpublish') {
|
||||
@@ -146,6 +254,8 @@ export class StrapiService {
|
||||
status = ArticleStatus.DRAFT;
|
||||
}
|
||||
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
@@ -153,26 +263,40 @@ export class StrapiService {
|
||||
slug: strapiArticle.slug,
|
||||
status,
|
||||
tags: [],
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiArticle.videoUrl || '',
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
strapiArticle.documentId,
|
||||
articleData,
|
||||
);
|
||||
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`);
|
||||
} catch (error: any) {
|
||||
this.logger.log(
|
||||
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
if (event === 'entry.unpublish' && error.response?.status === 404) {
|
||||
this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`);
|
||||
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||
this.logger.log(
|
||||
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||
);
|
||||
|
||||
// Try to update the article status to draft directly
|
||||
try {
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
status: ArticleStatus.DRAFT,
|
||||
};
|
||||
|
||||
|
||||
await this.articlesService.syncFromStrapi(strapiId, articleData);
|
||||
this.logger.log(`Marked article ${strapiId} as draft based on unpublish event`);
|
||||
this.logger.log(
|
||||
`Marked article ${strapiId} as draft based on unpublish event`,
|
||||
);
|
||||
} catch (syncError) {
|
||||
this.logger.error(
|
||||
`Failed to mark article ${strapiId} as draft:`,
|
||||
@@ -196,7 +320,7 @@ export class StrapiService {
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
||||
`${this.strapiUrl}/api/live-blogs`,
|
||||
`${this.strapiUrl}/api/live-blogs?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@@ -207,11 +331,21 @@ export class StrapiService {
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiLiveBlog.imagePosition ||
|
||||
'top') as ImagePosition,
|
||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
@@ -232,14 +366,21 @@ export class StrapiService {
|
||||
|
||||
async syncSingleLiveBlog(
|
||||
strapiId: string,
|
||||
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
|
||||
event?:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`);
|
||||
this.logger.log(
|
||||
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
|
||||
);
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@@ -247,41 +388,57 @@ export class StrapiService {
|
||||
);
|
||||
|
||||
const strapiLiveBlog = response.data.data;
|
||||
|
||||
|
||||
// For live blogs, we use the status from Strapi directly
|
||||
// but we might want to handle unpublish events differently
|
||||
let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status);
|
||||
|
||||
|
||||
// If it's an unpublish event, set to draft
|
||||
if (event === 'entry.unpublish') {
|
||||
status = LiveBlogStatus.DRAFT;
|
||||
}
|
||||
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status,
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
strapiLiveBlog.documentId,
|
||||
liveBlogData,
|
||||
);
|
||||
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`);
|
||||
} catch (error: any) {
|
||||
this.logger.log(
|
||||
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
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`);
|
||||
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||
this.logger.log(
|
||||
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||
);
|
||||
|
||||
// Try to update the live blog status to draft directly
|
||||
try {
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
status: LiveBlogStatus.DRAFT,
|
||||
};
|
||||
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
|
||||
this.logger.log(`Marked live blog ${strapiId} as draft based on unpublish event`);
|
||||
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:`,
|
||||
@@ -315,7 +472,12 @@ export class StrapiService {
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
|
||||
event:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
data: { documentId: string; model?: string },
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
@@ -323,7 +485,17 @@ export class StrapiService {
|
||||
);
|
||||
|
||||
if (event === 'entry.delete') {
|
||||
this.logger.log(`Handling delete for document: ${data.documentId}`);
|
||||
this.logger.log(
|
||||
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
|
||||
);
|
||||
|
||||
if (data.model === 'article') {
|
||||
await this.articlesService.removeByStrapiId(data.documentId);
|
||||
} else if (data.model === 'live-blog') {
|
||||
await this.liveBlogService.removeByStrapiId(data.documentId);
|
||||
} else {
|
||||
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -336,4 +508,162 @@ export class StrapiService {
|
||||
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateArticleStatusInStrapi(
|
||||
strapiId: string,
|
||||
status: ArticleStatus,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Updating article ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
|
||||
// Map our status to Strapi status
|
||||
let strapiStatus: string;
|
||||
switch (status) {
|
||||
case ArticleStatus.DRAFT:
|
||||
strapiStatus = 'draft';
|
||||
break;
|
||||
case ArticleStatus.PUBLISHED:
|
||||
strapiStatus = 'published';
|
||||
break;
|
||||
case ArticleStatus.ARCHIVED:
|
||||
strapiStatus = 'archived';
|
||||
break;
|
||||
default:
|
||||
strapiStatus = 'draft';
|
||||
}
|
||||
|
||||
// For archived status, we need to unpublish first if it's published
|
||||
if (status === ArticleStatus.ARCHIVED) {
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
|
||||
{},
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the status
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
||||
{ data: { status: strapiStatus } },
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to update article ${strapiId} status in Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateLiveBlogStatusInStrapi(
|
||||
strapiId: string,
|
||||
status: LiveBlogStatus,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
|
||||
// Map our status to Strapi status
|
||||
let strapiStatus: string;
|
||||
switch (status) {
|
||||
case LiveBlogStatus.DRAFT:
|
||||
strapiStatus = 'draft';
|
||||
break;
|
||||
case LiveBlogStatus.LIVE:
|
||||
strapiStatus = 'live';
|
||||
break;
|
||||
case LiveBlogStatus.ENDED:
|
||||
strapiStatus = 'ended';
|
||||
break;
|
||||
case LiveBlogStatus.ARCHIVED:
|
||||
strapiStatus = 'archived';
|
||||
break;
|
||||
default:
|
||||
strapiStatus = 'draft';
|
||||
}
|
||||
|
||||
// For archived status, we need to unpublish first if it's published
|
||||
if (status === LiveBlogStatus.ARCHIVED) {
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
|
||||
{},
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the status
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||
{ data: { status: strapiStatus } },
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to update live blog ${strapiId} status in Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Deleting article ${strapiId} from Strapi`);
|
||||
|
||||
await lastValueFrom(
|
||||
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
|
||||
headers: this.getHeaders(),
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete article ${strapiId} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
|
||||
|
||||
await lastValueFrom(
|
||||
this.httpService.delete(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete live blog ${strapiId} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,30 @@
|
||||
"videos",
|
||||
"audios"
|
||||
]
|
||||
},
|
||||
"imagePosition": {
|
||||
"type": "enumeration",
|
||||
"enum": ["top", "left", "right", "none"],
|
||||
"default": "top"
|
||||
},
|
||||
"imageSize": {
|
||||
"type": "enumeration",
|
||||
"enum": ["small", "medium", "large"],
|
||||
"default": "medium"
|
||||
},
|
||||
"videoUrl": {
|
||||
"type": "string",
|
||||
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
|
||||
"default": ""
|
||||
},
|
||||
"videoPosition": {
|
||||
"type": "enumeration",
|
||||
"enum": ["top", "inline", "bottom", "none"],
|
||||
"default": "inline"
|
||||
},
|
||||
"videoCaption": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
imagePosition: Schema.Attribute.Enumeration<
|
||||
['top', 'left', 'right', 'none']
|
||||
> &
|
||||
Schema.Attribute.DefaultTo<'top'>;
|
||||
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
|
||||
Schema.Attribute.DefaultTo<'medium'>;
|
||||
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
@@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||
videoPosition: Schema.Attribute.Enumeration<
|
||||
['top', 'inline', 'bottom', 'none']
|
||||
> &
|
||||
Schema.Attribute.DefaultTo<'inline'>;
|
||||
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Generated
+1471
-22
File diff suppressed because it is too large.
Load diff
@@ -28,7 +28,9 @@
|
||||
"@tanstack/react-router": "^1.144.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -8,11 +10,97 @@ import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
|
||||
export function AdminDashboardComponent() {
|
||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 });
|
||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 });
|
||||
// State for confirmation dialog and filters
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
|
||||
const [itemToDelete, setItemToDelete] = useState<{
|
||||
type: 'article' | 'liveBlog';
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
|
||||
limit: 50,
|
||||
status: showArchived ? 'archived' : 'draft,live,ended'
|
||||
});
|
||||
const { data: articlesData, isLoading: loadingArticles } = useArticles({
|
||||
limit: 50,
|
||||
status: showArchived ? 'archived' : 'draft,published'
|
||||
});
|
||||
const deleteArticleMutation = useDeleteArticle();
|
||||
const deleteLiveBlogMutation = useDeleteLiveBlog();
|
||||
const archiveArticleMutation = useArchiveArticle();
|
||||
const archiveLiveBlogMutation = useArchiveLiveBlog();
|
||||
const publishArticleMutation = usePublishArticle();
|
||||
const publishLiveBlogMutation = usePublishLiveBlog();
|
||||
|
||||
const liveBlogs = liveBlogsData?.data || [];
|
||||
const articles = articlesData?.data || [];
|
||||
|
||||
// No need to filter items - API already filters based on showArchived state
|
||||
const filteredLiveBlogs = liveBlogs;
|
||||
const filteredArticles = articles;
|
||||
|
||||
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('delete');
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('archive');
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (type === 'article') {
|
||||
await publishArticleMutation.mutateAsync({ id, status: 'published' });
|
||||
} else {
|
||||
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!itemToDelete) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (dialogType === 'delete') {
|
||||
if (itemToDelete.type === 'article') {
|
||||
await deleteArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
} else { // archive
|
||||
if (itemToDelete.type === 'article') {
|
||||
await archiveArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
setItemToDelete(null);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${dialogType}:`, error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAction = () => {
|
||||
setShowConfirmDialog(false);
|
||||
setItemToDelete(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -49,14 +137,20 @@ export function AdminDashboardComponent() {
|
||||
Управување со сите написи и live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
@@ -64,10 +158,10 @@ export function AdminDashboardComponent() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Live блогови</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{liveBlogs.length || 0}
|
||||
</Badge>
|
||||
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredLiveBlogs.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сите live блогови со статус и датум на креирање
|
||||
@@ -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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||
</div>
|
||||
) : liveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">Нема live блогови</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{liveBlogs.map((blog) => (
|
||||
) : filteredLiveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredLiveBlogs.map((blog) => (
|
||||
<div
|
||||
key={blog.id}
|
||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
@@ -96,8 +194,9 @@ export function AdminDashboardComponent() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to={`/admin/live-blogs/${blog.slug}` as any}
|
||||
<Link
|
||||
to="/admin/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{blog.title}
|
||||
@@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<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 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>
|
||||
</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>
|
||||
@@ -143,10 +269,10 @@ export function AdminDashboardComponent() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Написи</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{articles.length || 0}
|
||||
</Badge>
|
||||
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredArticles.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">Нема написи</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/">Креирај нов напис</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{articles.map((article) => (
|
||||
) : filteredArticles.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/">Креирај нов напис</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredArticles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
@@ -175,8 +305,9 @@ export function AdminDashboardComponent() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to={`/articles/${article.id}` as any}
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{article.title}
|
||||
@@ -201,14 +332,41 @@ export function AdminDashboardComponent() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/articles/${article.id}` as any}>Уреди</Link>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to={`/articles/${article.id}` as any} target="_blank">
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePublishClick('article', article.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Објави
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Архивирај
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Избриши
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,42 +377,74 @@ export function AdminDashboardComponent() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* Quick Stats - Only show when not viewing archived items */}
|
||||
{!showArchived && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{dialogType === 'delete'
|
||||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||||
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||
|
||||
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -65,16 +69,101 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.featuredImage && (
|
||||
<img
|
||||
src={data.featuredImage}
|
||||
alt={data.title}
|
||||
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
|
||||
/>
|
||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||
<div className={`relative mb-4 ${
|
||||
data.imagePosition === 'top'
|
||||
? 'w-full mb-8'
|
||||
: data.imagePosition === 'left'
|
||||
? 'float-none md:float-left mr-0 md:mr-6'
|
||||
: 'float-none md:float-right ml-0 md:ml-6'
|
||||
}`}>
|
||||
<img
|
||||
src={data.featuredImage}
|
||||
alt={data.title}
|
||||
className={`rounded-xl object-cover ${
|
||||
data.imagePosition === 'top'
|
||||
? data.imageSize === 'small'
|
||||
? 'h-32'
|
||||
: data.imageSize === 'medium'
|
||||
? 'h-48'
|
||||
: 'h-64 md:h-96'
|
||||
: data.imageSize === 'small'
|
||||
? 'w-full md:w-48 h-32'
|
||||
: data.imageSize === 'medium'
|
||||
? 'w-full md:w-64 h-48'
|
||||
: 'w-full md:w-96 h-64'
|
||||
}`}
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image:', data.featuredImage, e);
|
||||
e.currentTarget.style.display = 'none';
|
||||
// Show fallback
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (fallback) {
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<span>Image not available</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video rendering */}
|
||||
{data.videoUrl && data.videoPosition !== 'none' && (
|
||||
<div className={getVideoPositionClasses(data.videoPosition)}>
|
||||
<YouTubeEmbed
|
||||
url={data.videoUrl}
|
||||
title={data.title}
|
||||
caption={data.videoCaption}
|
||||
autoplay={false}
|
||||
controls={true}
|
||||
modestbranding={true}
|
||||
showRelated={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
|
||||
<div className="text-lg leading-relaxed mb-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
img: (props) => (
|
||||
<img
|
||||
{...props}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
alt={props.alt || 'Article image'}
|
||||
/>
|
||||
),
|
||||
a: (props) => {
|
||||
// Check if the link is a YouTube URL
|
||||
const videoId = extractYouTubeVideoId(props.href || '');
|
||||
if (videoId) {
|
||||
return (
|
||||
<div className="my-6">
|
||||
<YouTubeEmbed
|
||||
url={props.href || ''}
|
||||
title={props.title || 'YouTube video'}
|
||||
autoplay={false}
|
||||
controls={true}
|
||||
modestbranding={true}
|
||||
showRelated={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Regular link
|
||||
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 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;
|
||||
setReconnectAttempts(nextAttempt);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`);
|
||||
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
|
||||
createConnection();
|
||||
}, optionsRef.current.reconnectInterval);
|
||||
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) {
|
||||
}, optionsRef.current.reconnectInterval || 3000);
|
||||
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
|
||||
setConnectionError('Failed to reconnect after multiple attempts');
|
||||
}
|
||||
};
|
||||
|
||||
+126
-2
@@ -11,6 +11,11 @@ export interface Article {
|
||||
excerpt: string | null;
|
||||
slug: string;
|
||||
featuredImage: string;
|
||||
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize: 'small' | 'medium' | 'large';
|
||||
videoUrl: string;
|
||||
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption: string;
|
||||
tags: string[];
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
views: number;
|
||||
@@ -45,12 +50,48 @@ export interface FindArticlesParams {
|
||||
category?: string;
|
||||
author?: string;
|
||||
tag?: string;
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CreateArticleDto {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
slug?: string;
|
||||
featuredImage?: string;
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
strapiId?: string;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize?: 'small' | 'medium' | 'large';
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export interface UpdateArticleDto {
|
||||
title?: string;
|
||||
content?: string;
|
||||
excerpt?: string;
|
||||
slug?: string;
|
||||
featuredImage?: string;
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
strapiId?: string;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
imagePosition?: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize?: 'small' | 'medium' | 'large';
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
|
||||
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -97,6 +138,63 @@ export async function fetchArticleById(id: string): Promise<Article> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteArticle(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete article');
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveArticle(id: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to archive article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to publish article');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Live Blog Types
|
||||
export interface LiveBlogUpdate {
|
||||
id: string;
|
||||
@@ -126,6 +224,12 @@ export interface LiveBlog {
|
||||
strapiId: string | null;
|
||||
authorId: string | null;
|
||||
categoryId: string | null;
|
||||
featuredImage: string;
|
||||
imagePosition: 'top' | 'left' | 'right' | 'none';
|
||||
imageSize: 'small' | 'medium' | 'large';
|
||||
videoUrl: string;
|
||||
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption: string;
|
||||
viewCount: number;
|
||||
author?: {
|
||||
id: string;
|
||||
@@ -162,7 +266,7 @@ export interface LiveBlogUpdatesResponse {
|
||||
export interface FindLiveBlogsParams {
|
||||
category?: string;
|
||||
author?: string;
|
||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
@@ -361,3 +465,23 @@ export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
throw new Error('Failed to delete live blog');
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to archive live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to publish live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
export function useArticles(params: api.FindArticlesParams = {}) {
|
||||
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createArticle,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
|
||||
api.updateArticle(id, dto),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.deleteArticle,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
// Also invalidate any specific article queries
|
||||
queryClient.removeQueries({ queryKey: ['article', variables] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.archiveArticle,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
|
||||
api.publishArticle(id, status),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,4 +155,31 @@ export function useDeleteLiveBlog() {
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.archiveLiveBlog(id),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
|
||||
api.publishLiveBlog(id, status),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Generated
+1525
-1234
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
1. role based auth [admin, contributor, user]
|
||||
3. social media integration [telegram, whatsup, facebook, viber]
|
||||
4. share with functionality
|
||||
|
||||
lets implement a role based auth
|
||||
admin can create, edit and delete articles and liveblogs
|
||||
admin can create contributors
|
||||
contributors can create, edit and delete articles and liveblogs
|
||||
user can react(like, dislike) to articles and write comments
|
||||
Reference in new issue
Block a user