delete/archive implemented

This commit is contained in:
echo 2026-02-03 23:14:36 +01:00
parent 89b8687431
commit 9ca05f5ea1
13 changed files with 680 additions and 163 deletions

View File

@ -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);
}
} }

View File

@ -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()

View File

@ -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`);

View File

@ -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(

View File

@ -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

View File

@ -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;

View File

@ -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;
@ -141,7 +149,7 @@ export class StrapiService {
try { try {
this.logger.log('Starting articles sync from Strapi...'); this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>( this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles?populate=*`, `${this.strapiUrl}/api/articles?populate=*`,
{ {
@ -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,12 +201,19 @@ 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>>(
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`, `${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{ {
@ -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:`,
@ -276,7 +300,7 @@ export class StrapiService {
try { try {
this.logger.log('Starting live blogs sync from Strapi...'); this.logger.log('Starting live blogs sync from Strapi...');
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>( this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs?populate=*`, `${this.strapiUrl}/api/live-blogs?populate=*`,
{ {
@ -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,12 +348,19 @@ 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>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`, `${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{ {
@ -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);

View File

@ -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':
@ -49,14 +137,20 @@ export function AdminDashboardComponent() {
Управување со сите написи и live блогови Управување со сите написи и live блогови
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button asChild variant="outline"> <Button
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link> variant={showArchived ? "default" : "outline"}
</Button> onClick={() => setShowArchived(!showArchived)}
<Button asChild> >
<Link to="/">Назад кон сајтот</Link> {showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button> </Button>
</div> <Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild>
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
@ -64,10 +158,10 @@ 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>
Сите live блогови со статус и датум на креирање Сите 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> <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">
<Button asChild variant="outline" className="mt-4"> {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link> </p>
</Button> {!showArchived && (
</div> <Button asChild variant="outline" className="mt-4">
) : ( <Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
<div className="space-y-4"> </Button>
{liveBlogs.map((blog) => ( )}
</div>
) : (
<div className="space-y-4">
{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"
@ -96,8 +194,9 @@ export function AdminDashboardComponent() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<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,10 +269,10 @@ 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">
<Button asChild variant="outline" className="mt-4"> {showArchived ? 'Нема архивирани написи' : 'Нема написи'}
<Link to="/">Креирај нов напис</Link> </p>
</Button> {!showArchived && (
</div> <Button asChild variant="outline" className="mt-4">
) : ( <Link to="/">Креирај нов напис</Link>
<div className="space-y-4"> </Button>
{articles.map((article) => ( )}
</div>
) : (
<div className="space-y-4">
{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"
@ -175,8 +305,9 @@ export function AdminDashboardComponent() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<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}
@ -201,14 +332,41 @@ 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,42 +377,74 @@ export function AdminDashboardComponent() {
</Card> </Card>
</div> </div>
{/* Quick Stats */} {/* Quick Stats - Only show when not viewing archived items */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {!showArchived && (
<Card> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<CardContent className="pt-6"> <Card>
<div className="text-2xl font-bold"> <CardContent className="pt-6">
{liveBlogs.filter(b => b.status === 'live').length || 0} <div className="text-2xl font-bold">
</div> {liveBlogs.filter(b => b.status === 'live').length || 0}
<p className="text-sm text-muted-foreground">Активни live блогови</p> </div>
</CardContent> <p className="text-sm text-muted-foreground">Активни live блогови</p>
</Card> </CardContent>
<Card> </Card>
<CardContent className="pt-6"> <Card>
<div className="text-2xl font-bold"> <CardContent className="pt-6">
{articles.filter(a => a.status === 'published').length || 0} <div className="text-2xl font-bold">
</div> {articles.filter(a => a.status === 'published').length || 0}
<p className="text-sm text-muted-foreground">Објавени написи</p> </div>
</CardContent> <p className="text-sm text-muted-foreground">Објавени написи</p>
</Card> </CardContent>
<Card> </Card>
<CardContent className="pt-6"> <Card>
<div className="text-2xl font-bold"> <CardContent className="pt-6">
{liveBlogs.filter(b => b.isPinned).length || 0} <div className="text-2xl font-bold">
</div> {liveBlogs.filter(b => b.isPinned).length || 0}
<p className="text-sm text-muted-foreground">Закачени live блогови</p> </div>
</CardContent> <p className="text-sm text-muted-foreground">Закачени live блогови</p>
</Card> </CardContent>
<Card> </Card>
<CardContent className="pt-6"> <Card>
<div className="text-2xl font-bold"> <CardContent className="pt-6">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + <div className="text-2xl font-bold">
(articles.reduce((sum, a) => sum + a.views, 0) || 0)} {(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
</div> (articles.reduce((sum, a) => sum + a.views, 0) || 0)}
<p className="text-sm text-muted-foreground">Вкупно прегледи</p> </div>
</CardContent> <p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</Card> </CardContent>
</div> </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>
);
}

View File

@ -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();
}

View File

@ -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] });
},
});
}

View File

@ -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'] });
},
});
}

View File

@ -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