delete/archive implemented
This commit is contained in:
parent
89b8687431
commit
9ca05f5ea1
@ -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, ImagePosition, ImageSize, VideoPosition } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
export class CreateArticleDto {
|
export class CreateArticleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -148,8 +154,8 @@ export class FindArticlesDto {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ArticleStatus)
|
@IsString()
|
||||||
status?: ArticleStatus;
|
status?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -28,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 });
|
||||||
@ -109,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>,
|
||||||
@ -129,7 +143,9 @@ export class ArticlesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||||
const article = await this.articleRepository.findOne({ where: { strapiId } });
|
const article = await this.articleRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -443,7 +451,9 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||||
const liveBlog = await this.liveBlogRepository.findOne({ where: { strapiId } });
|
const liveBlog = await this.liveBlogRepository.findOne({
|
||||||
|
where: { strapiId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!liveBlog) {
|
if (!liveBlog) {
|
||||||
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
||||||
@ -451,7 +461,9 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.liveBlogRepository.remove(liveBlog);
|
await this.liveBlogRepository.remove(liveBlog);
|
||||||
this.logger.log(`Successfully deleted live blog with strapiId: ${strapiId}`);
|
this.logger.log(
|
||||||
|
`Successfully deleted live blog with strapiId: ${strapiId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
|
|||||||
@ -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' | 'entry.unpublish';
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish';
|
||||||
model: string;
|
model: string;
|
||||||
entry: {
|
entry: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|||||||
@ -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, ImagePosition, ImageSize, VideoPosition } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
interface StrapiArticle {
|
interface StrapiArticle {
|
||||||
id: number;
|
id: number;
|
||||||
@ -111,7 +117,9 @@ export class StrapiService {
|
|||||||
return imageUrl;
|
return imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractLiveBlogImageUrl(strapiLiveBlog: StrapiLiveBlog): string | undefined {
|
private extractLiveBlogImageUrl(
|
||||||
|
strapiLiveBlog: StrapiLiveBlog,
|
||||||
|
): string | undefined {
|
||||||
// Try to get image from img field first (single image)
|
// Try to get image from img field first (single image)
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
@ -166,10 +174,12 @@ export class StrapiService {
|
|||||||
: ArticleStatus.DRAFT,
|
: ArticleStatus.DRAFT,
|
||||||
tags: [],
|
tags: [],
|
||||||
featuredImage: imageUrl,
|
featuredImage: imageUrl,
|
||||||
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
imagePosition: (strapiArticle.imagePosition ||
|
||||||
|
'top') as ImagePosition,
|
||||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||||
videoUrl: strapiArticle.videoUrl || '',
|
videoUrl: strapiArticle.videoUrl || '',
|
||||||
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
|
videoPosition: (strapiArticle.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
videoCaption: strapiArticle.videoCaption || '',
|
videoCaption: strapiArticle.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -191,10 +201,17 @@ export class StrapiService {
|
|||||||
|
|
||||||
async syncSingleArticle(
|
async syncSingleArticle(
|
||||||
strapiId: string,
|
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> {
|
): Promise<void> {
|
||||||
try {
|
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
||||||
@ -233,7 +250,8 @@ export class StrapiService {
|
|||||||
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
||||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||||
videoUrl: strapiArticle.videoUrl || '',
|
videoUrl: strapiArticle.videoUrl || '',
|
||||||
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
|
videoPosition: (strapiArticle.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
videoCaption: strapiArticle.videoCaption || '',
|
videoCaption: strapiArticle.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -241,11 +259,15 @@ export class StrapiService {
|
|||||||
strapiArticle.documentId,
|
strapiArticle.documentId,
|
||||||
articleData,
|
articleData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`);
|
this.logger.log(
|
||||||
|
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
// 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) {
|
if (event === 'entry.unpublish' && error.response?.status === 404) {
|
||||||
this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`);
|
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 to update the article status to draft directly
|
||||||
try {
|
try {
|
||||||
@ -254,7 +276,9 @@ export class StrapiService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.articlesService.syncFromStrapi(strapiId, articleData);
|
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) {
|
} catch (syncError) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to mark article ${strapiId} as draft:`,
|
`Failed to mark article ${strapiId} as draft:`,
|
||||||
@ -297,10 +321,12 @@ export class StrapiService {
|
|||||||
slug: strapiLiveBlog.slug,
|
slug: strapiLiveBlog.slug,
|
||||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
||||||
featuredImage: imageUrl,
|
featuredImage: imageUrl,
|
||||||
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
imagePosition: (strapiLiveBlog.imagePosition ||
|
||||||
|
'top') as ImagePosition,
|
||||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||||
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
|
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -322,10 +348,17 @@ export class StrapiService {
|
|||||||
|
|
||||||
async syncSingleLiveBlog(
|
async syncSingleLiveBlog(
|
||||||
strapiId: string,
|
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> {
|
): Promise<void> {
|
||||||
try {
|
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||||
@ -358,7 +391,8 @@ export class StrapiService {
|
|||||||
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
||||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||||
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
|
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||||
|
'inline') as VideoPosition,
|
||||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -366,11 +400,15 @@ export class StrapiService {
|
|||||||
strapiLiveBlog.documentId,
|
strapiLiveBlog.documentId,
|
||||||
liveBlogData,
|
liveBlogData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`);
|
this.logger.log(
|
||||||
|
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
// 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) {
|
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`);
|
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 to update the live blog status to draft directly
|
||||||
try {
|
try {
|
||||||
@ -379,7 +417,9 @@ export class StrapiService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
|
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) {
|
} catch (syncError) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to mark live blog ${strapiId} as draft:`,
|
`Failed to mark live blog ${strapiId} as draft:`,
|
||||||
@ -413,7 +453,12 @@ export class StrapiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleWebhook(
|
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 },
|
data: { documentId: string; model?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@ -421,7 +466,9 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (event === 'entry.delete') {
|
if (event === 'entry.delete') {
|
||||||
this.logger.log(`Handling delete for document: ${data.documentId}, model: ${data.model}`);
|
this.logger.log(
|
||||||
|
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.model === 'article') {
|
if (data.model === 'article') {
|
||||||
await this.articlesService.removeByStrapiId(data.documentId);
|
await this.articlesService.removeByStrapiId(data.documentId);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -50,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();
|
||||||
@ -102,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;
|
||||||
@ -173,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;
|
||||||
@ -372,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();
|
||||||
|
}
|
||||||
|
|||||||
@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
6
todos.md
6
todos.md
@ -2,3 +2,9 @@
|
|||||||
2. [ ] video
|
2. [ ] video
|
||||||
3. social media integration [telegram, whatsup, facebook, viber]
|
3. social media integration [telegram, whatsup, facebook, viber]
|
||||||
4. share with functionality
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user