diff --git a/backend/src/main.ts b/backend/src/main.ts index 3fd4ced..d4964c5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -15,11 +15,11 @@ async function bootstrap() { }); app.setGlobalPrefix('api/v1'); - + const port = process.env.PORT ?? 3000; const host = '0.0.0.0'; // Bind to all interfaces for Docker await app.listen(port, host); - + console.log(`Application is running on: http://${host}:${port}`); } void bootstrap(); diff --git a/backend/src/modules/articles.controller.ts b/backend/src/modules/articles.controller.ts index 7c549e7..8862c17 100644 --- a/backend/src/modules/articles.controller.ts +++ b/backend/src/modules/articles.controller.ts @@ -4,6 +4,7 @@ import { Post, Put, Delete, + Patch, Body, Param, Query, @@ -15,6 +16,7 @@ import { UpdateArticleDto, FindArticlesDto, } from './articles.dto'; +import { ArticleStatus } from './entities'; @Controller('articles') export class ArticlesController { @@ -54,4 +56,17 @@ export class ArticlesController { remove(@Param('id') id: string) { return this.articlesService.remove(id); } + + @Patch(':id/archive') + archive(@Param('id') id: string) { + return this.articlesService.archive(id); + } + + @Patch(':id/publish') + publish( + @Param('id') id: string, + @Query('status') status?: ArticleStatus, + ) { + return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED); + } } diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 116147c..0abc5a1 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -8,7 +8,13 @@ import { IsBoolean, IsDate, } from 'class-validator'; -import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities'; +import { + ArticleStatus, + LiveBlogStatus, + ImagePosition, + ImageSize, + VideoPosition, +} from './entities'; export class CreateArticleDto { @IsString() @@ -148,8 +154,8 @@ export class FindArticlesDto { tag?: string; @IsOptional() - @IsEnum(ArticleStatus) - status?: ArticleStatus; + @IsString() + status?: string; @IsOptional() @IsString() diff --git a/backend/src/modules/articles.service.ts b/backend/src/modules/articles.service.ts index 955e0a5..aa35b48 100644 --- a/backend/src/modules/articles.service.ts +++ b/backend/src/modules/articles.service.ts @@ -28,21 +28,23 @@ export class ArticlesService { async findAll( dto: FindArticlesDto, ): Promise<{ data: Article[]; total: number }> { - const { - category, - author, - tag, - status = ArticleStatus.PUBLISHED, - search, - page = 1, - limit = 10, - } = dto; + const { category, author, tag, status, search, page = 1, limit = 10 } = dto; const queryBuilder = this.articleRepository .createQueryBuilder('article') .leftJoinAndSelect('article.author', 'author') - .leftJoinAndSelect('article.category', 'category') - .where('article.status = :status', { status }); + .leftJoinAndSelect('article.category', 'category'); + + // Handle status filter - can be single value or comma-separated list + if (status) { + if (typeof status === 'string' && status.includes(',')) { + const statuses = status.split(',').map((s) => s.trim()); + queryBuilder.where('article.status IN (:...statuses)', { statuses }); + } else { + queryBuilder.where('article.status = :status', { status }); + } + } + // If no status specified, return all articles (for admin dashboard) if (category) { queryBuilder.andWhere('category.slug = :category', { category }); @@ -109,6 +111,18 @@ export class ArticlesService { await this.articleRepository.remove(article); } + async archive(id: string): Promise
{ + 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
{ + const article = await this.findOne(id); + article.status = status; + return await this.articleRepository.save(article); + } + async syncFromStrapi( strapiId: string, data: Partial, @@ -129,8 +143,10 @@ export class ArticlesService { } async removeByStrapiId(strapiId: string): Promise { - const article = await this.articleRepository.findOne({ where: { strapiId } }); - + const article = await this.articleRepository.findOne({ + where: { strapiId }, + }); + if (!article) { this.logger.warn(`Article with strapiId ${strapiId} not found`); return; diff --git a/backend/src/modules/live-blog.controller.ts b/backend/src/modules/live-blog.controller.ts index d0e52a5..4e66209 100644 --- a/backend/src/modules/live-blog.controller.ts +++ b/backend/src/modules/live-blog.controller.ts @@ -4,6 +4,7 @@ import { Post, Put, Delete, + Patch, Body, Param, Query, @@ -21,6 +22,7 @@ import { CreateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto, } from './articles.dto'; +import { LiveBlogStatus } from './entities'; @Controller('live-blogs') export class LiveBlogController { @@ -121,6 +123,19 @@ export class LiveBlogController { return this.liveBlogService.removeUpdate(liveBlogId, updateId); } + @Patch(':id/archive') + archive(@Param('id') id: string) { + return this.liveBlogService.archive(id); + } + + @Patch(':id/publish') + publish( + @Param('id') id: string, + @Query('status') status?: LiveBlogStatus, + ) { + return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT); + } + // SSE endpoint for real-time updates @Get(':id/stream') stream( diff --git a/backend/src/modules/live-blog.service.ts b/backend/src/modules/live-blog.service.ts index 4db1b2c..6d2c65f 100644 --- a/backend/src/modules/live-blog.service.ts +++ b/backend/src/modules/live-blog.service.ts @@ -116,13 +116,9 @@ export class LiveBlogService implements OnModuleInit { } else { queryBuilder.where('liveBlog.status = :status', { status }); } - } else if (!isPinned) { - // Default to live blogs if no status specified AND not querying for pinned blogs - // Pinned blogs can be either live or ended - queryBuilder.where('liveBlog.status = :status', { - status: LiveBlogStatus.LIVE, - }); } + // If no status specified, return all live blogs (for admin dashboard) + // Note: Pinned blogs query should still work without status filter if (category) { queryBuilder.andWhere('category.slug = :category', { category }); @@ -262,6 +258,18 @@ export class LiveBlogService implements OnModuleInit { await this.liveBlogRepository.remove(liveBlog); } + async archive(id: string): Promise { + 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 { + const liveBlog = await this.findOne(id); + liveBlog.status = status; + return await this.liveBlogRepository.save(liveBlog); + } + // Live Blog Update CRUD operations async createUpdate( dto: CreateLiveBlogUpdateDto, @@ -380,7 +388,7 @@ export class LiveBlogService implements OnModuleInit { const keepAliveInterval = setInterval(() => { try { response.write(`: keep-alive\n\n`); - } catch (error) { + } catch { // Client disconnected, stop sending keep-alive clearInterval(keepAliveInterval); } @@ -443,15 +451,19 @@ export class LiveBlogService implements OnModuleInit { } async removeByStrapiId(strapiId: string): Promise { - const liveBlog = await this.liveBlogRepository.findOne({ where: { strapiId } }); - + const liveBlog = await this.liveBlogRepository.findOne({ + where: { strapiId }, + }); + if (!liveBlog) { this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`); return; } await this.liveBlogRepository.remove(liveBlog); - this.logger.log(`Successfully deleted live blog with strapiId: ${strapiId}`); + this.logger.log( + `Successfully deleted live blog with strapiId: ${strapiId}`, + ); } // Utility methods diff --git a/backend/src/modules/strapi.controller.ts b/backend/src/modules/strapi.controller.ts index cf4fede..cf6a02b 100644 --- a/backend/src/modules/strapi.controller.ts +++ b/backend/src/modules/strapi.controller.ts @@ -2,7 +2,12 @@ import { Controller, Post, Body, Logger } from '@nestjs/common'; import { StrapiService } from './strapi.service'; interface WebhookBody { - event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish'; + event: + | 'entry.create' + | 'entry.update' + | 'entry.delete' + | 'entry.publish' + | 'entry.unpublish'; model: string; entry: { documentId: string; diff --git a/backend/src/modules/strapi.service.ts b/backend/src/modules/strapi.service.ts index 225a74f..6a8d30a 100644 --- a/backend/src/modules/strapi.service.ts +++ b/backend/src/modules/strapi.service.ts @@ -5,7 +5,13 @@ import { lastValueFrom } from 'rxjs'; import { ArticlesService } from './articles.service'; import { LiveBlogService } from './live-blog.service'; import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto'; -import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities'; +import { + ArticleStatus, + LiveBlogStatus, + ImagePosition, + ImageSize, + VideoPosition, +} from './entities'; interface StrapiArticle { id: number; @@ -88,18 +94,18 @@ export class StrapiService { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined { // Try to get image from img field first (single image) let imageUrl: string | undefined; - + if (strapiArticle.img?.url) { imageUrl = strapiArticle.img.url; } else if (strapiArticle.media?.[0]?.url) { // Try to get first image from media field (multiple images) imageUrl = strapiArticle.media[0].url; } - + if (!imageUrl) { return undefined; } - + // If URL is relative, prepend Strapi base URL if (imageUrl.startsWith('/')) { // Convert Docker service URL to localhost for frontend access @@ -107,25 +113,27 @@ export class StrapiService { const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:'); return `${frontendStrapiUrl}${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) let imageUrl: string | undefined; - + if (strapiLiveBlog.img?.url) { imageUrl = strapiLiveBlog.img.url; } else if (strapiLiveBlog.media?.[0]?.url) { // Try to get first image from media field (multiple images) imageUrl = strapiLiveBlog.media[0].url; } - + if (!imageUrl) { return undefined; } - + // If URL is relative, prepend Strapi base URL if (imageUrl.startsWith('/')) { // Convert Docker service URL to localhost for frontend access @@ -133,7 +141,7 @@ export class StrapiService { const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:'); return `${frontendStrapiUrl}${imageUrl}`; } - + return imageUrl; } @@ -141,7 +149,7 @@ export class StrapiService { try { this.logger.log('Starting articles sync from Strapi...'); - const response = await lastValueFrom( + const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/articles?populate=*`, { @@ -155,7 +163,7 @@ export class StrapiService { for (const strapiArticle of strapiArticles) { const imageUrl = this.extractImageUrl(strapiArticle); - + const articleData: Partial = { title: strapiArticle.title, excerpt: strapiArticle.description, @@ -166,10 +174,12 @@ export class StrapiService { : ArticleStatus.DRAFT, tags: [], featuredImage: imageUrl, - imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, + imagePosition: (strapiArticle.imagePosition || + 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, videoUrl: strapiArticle.videoUrl || '', - videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, + videoPosition: (strapiArticle.videoPosition || + 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', }; @@ -191,12 +201,19 @@ export class StrapiService { async syncSingleArticle( strapiId: string, - event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', + event?: + | 'entry.create' + | 'entry.update' + | 'entry.delete' + | 'entry.publish' + | 'entry.unpublish', ): Promise { 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>( `${this.strapiUrl}/api/articles/${strapiId}?populate=*`, { @@ -206,7 +223,7 @@ export class StrapiService { ); const strapiArticle = response.data.data; - + // Determine status based on publishedAt and event type let status: ArticleStatus; if (event === 'entry.unpublish') { @@ -221,7 +238,7 @@ export class StrapiService { } const imageUrl = this.extractImageUrl(strapiArticle); - + const articleData: Partial = { title: strapiArticle.title, excerpt: strapiArticle.description, @@ -233,7 +250,8 @@ export class StrapiService { imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, videoUrl: strapiArticle.videoUrl || '', - videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, + videoPosition: (strapiArticle.videoPosition || + 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', }; @@ -241,20 +259,26 @@ export class StrapiService { strapiArticle.documentId, 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) { // If we get a 404 and it's an unpublish event, we can still mark it as draft if (event === 'entry.unpublish' && error.response?.status === 404) { - this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`); - + this.logger.log( + `Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`, + ); + // Try to update the article status to draft directly try { const articleData: Partial = { status: ArticleStatus.DRAFT, }; - + await this.articlesService.syncFromStrapi(strapiId, articleData); - this.logger.log(`Marked article ${strapiId} as draft based on unpublish event`); + this.logger.log( + `Marked article ${strapiId} as draft based on unpublish event`, + ); } catch (syncError) { this.logger.error( `Failed to mark article ${strapiId} as draft:`, @@ -276,7 +300,7 @@ export class StrapiService { try { this.logger.log('Starting live blogs sync from Strapi...'); - const response = await lastValueFrom( + const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/live-blogs?populate=*`, { @@ -290,17 +314,19 @@ export class StrapiService { for (const strapiLiveBlog of strapiLiveBlogs) { const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); - + const liveBlogData: Partial = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, slug: strapiLiveBlog.slug, status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status), featuredImage: imageUrl, - imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, + imagePosition: (strapiLiveBlog.imagePosition || + 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, videoUrl: strapiLiveBlog.videoUrl || '', - videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, + videoPosition: (strapiLiveBlog.videoPosition || + 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', }; @@ -322,12 +348,19 @@ export class StrapiService { async syncSingleLiveBlog( strapiId: string, - event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', + event?: + | 'entry.create' + | 'entry.update' + | 'entry.delete' + | 'entry.publish' + | 'entry.unpublish', ): Promise { 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>( `${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`, { @@ -337,18 +370,18 @@ export class StrapiService { ); const strapiLiveBlog = response.data.data; - + // For live blogs, we use the status from Strapi directly // but we might want to handle unpublish events differently let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status); - + // If it's an unpublish event, set to draft if (event === 'entry.unpublish') { status = LiveBlogStatus.DRAFT; } const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); - + const liveBlogData: Partial = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, @@ -358,7 +391,8 @@ export class StrapiService { imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, videoUrl: strapiLiveBlog.videoUrl || '', - videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, + videoPosition: (strapiLiveBlog.videoPosition || + 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', }; @@ -366,20 +400,26 @@ export class StrapiService { strapiLiveBlog.documentId, 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) { // If we get a 404 and it's an unpublish event, we can still mark it as draft if (event === 'entry.unpublish' && error.response?.status === 404) { - this.logger.log(`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`); - + this.logger.log( + `Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`, + ); + // Try to update the live blog status to draft directly try { const liveBlogData: Partial = { status: LiveBlogStatus.DRAFT, }; - + await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData); - this.logger.log(`Marked live blog ${strapiId} as draft based on unpublish event`); + this.logger.log( + `Marked live blog ${strapiId} as draft based on unpublish event`, + ); } catch (syncError) { this.logger.error( `Failed to mark live blog ${strapiId} as draft:`, @@ -413,7 +453,12 @@ export class StrapiService { } async handleWebhook( - event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', + event: + | 'entry.create' + | 'entry.update' + | 'entry.delete' + | 'entry.publish' + | 'entry.unpublish', data: { documentId: string; model?: string }, ): Promise { this.logger.log( @@ -421,8 +466,10 @@ export class StrapiService { ); 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') { await this.articlesService.removeByStrapiId(data.documentId); } else if (data.model === 'live-blog') { diff --git a/frontend/src/components/routes/AdminDashboardComponent.tsx b/frontend/src/components/routes/AdminDashboardComponent.tsx index b72562e..5c396a6 100644 --- a/frontend/src/components/routes/AdminDashboardComponent.tsx +++ b/frontend/src/components/routes/AdminDashboardComponent.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { useLiveBlogs } from '@/queries/live-blogs'; -import { useArticles } from '@/queries/articles'; +import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles'; +import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs'; import { Link } from '@tanstack/react-router'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -8,11 +10,97 @@ import { format } from 'date-fns'; import { mk } from 'date-fns/locale'; export function AdminDashboardComponent() { - const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 }); - const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 }); + // State for confirmation dialog and filters + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete'); + const [itemToDelete, setItemToDelete] = useState<{ + type: 'article' | 'liveBlog'; + id: string; + title: string; + } | null>(null); + const [isProcessing, setIsProcessing] = useState(false); + const [showArchived, setShowArchived] = useState(false); + + const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ + limit: 50, + status: showArchived ? 'archived' : 'draft,live,ended' + }); + const { data: articlesData, isLoading: loadingArticles } = useArticles({ + limit: 50, + status: showArchived ? 'archived' : 'draft,published' + }); + const deleteArticleMutation = useDeleteArticle(); + const deleteLiveBlogMutation = useDeleteLiveBlog(); + const archiveArticleMutation = useArchiveArticle(); + const archiveLiveBlogMutation = useArchiveLiveBlog(); + const publishArticleMutation = usePublishArticle(); + const publishLiveBlogMutation = usePublishLiveBlog(); const liveBlogs = liveBlogsData?.data || []; const articles = articlesData?.data || []; + + // No need to filter items - API already filters based on showArchived state + const filteredLiveBlogs = liveBlogs; + const filteredArticles = articles; + + const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => { + setItemToDelete({ type, id, title }); + setDialogType('delete'); + setShowConfirmDialog(true); + }; + + const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => { + setItemToDelete({ type, id, title }); + setDialogType('archive'); + setShowConfirmDialog(true); + }; + + const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => { + setIsProcessing(true); + try { + if (type === 'article') { + await publishArticleMutation.mutateAsync({ id, status: 'published' }); + } else { + await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' }); + } + } catch (error) { + console.error('Failed to publish:', error); + } finally { + setIsProcessing(false); + } + }; + + const handleConfirmAction = async () => { + if (!itemToDelete) return; + + setIsProcessing(true); + try { + if (dialogType === 'delete') { + if (itemToDelete.type === 'article') { + await deleteArticleMutation.mutateAsync(itemToDelete.id); + } else { + await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); + } + } else { // archive + if (itemToDelete.type === 'article') { + await archiveArticleMutation.mutateAsync(itemToDelete.id); + } else { + await archiveLiveBlogMutation.mutateAsync(itemToDelete.id); + } + } + setShowConfirmDialog(false); + setItemToDelete(null); + } catch (error) { + console.error(`Failed to ${dialogType}:`, error); + } finally { + setIsProcessing(false); + } + }; + + const handleCancelAction = () => { + setShowConfirmDialog(false); + setItemToDelete(null); + }; const getStatusColor = (status: string) => { switch (status) { @@ -49,14 +137,20 @@ export function AdminDashboardComponent() { Управување со сите написи и live блогови

-
- - -
+
+ + + +
@@ -64,10 +158,10 @@ export function AdminDashboardComponent() { - Live блогови - - {liveBlogs.length || 0} - + {showArchived ? 'Архивирани Live блогови' : 'Live блогови'} + + {filteredLiveBlogs.length || 0} + Сите live блогови со статус и датум на креирање @@ -79,16 +173,20 @@ export function AdminDashboardComponent() {

Вчитување...

- ) : liveBlogs.length === 0 ? ( -
-

Нема live блогови

- -
- ) : ( -
- {liveBlogs.map((blog) => ( + ) : filteredLiveBlogs.length === 0 ? ( +
+

+ {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'} +

+ {!showArchived && ( + + )} +
+ ) : ( +
+ {filteredLiveBlogs.map((blog) => (
- {blog.title} @@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
+ {showArchived ? ( + + ) : ( + + )} +
@@ -143,10 +269,10 @@ export function AdminDashboardComponent() { - Написи - - {articles.length || 0} - + {showArchived ? 'Архивирани написи' : 'Написи'} + + {filteredArticles.length || 0} + Сите написи со статус и датум на креирање @@ -158,16 +284,20 @@ export function AdminDashboardComponent() {

Вчитување...

- ) : articles.length === 0 ? ( -
-

Нема написи

- -
- ) : ( -
- {articles.map((article) => ( + ) : filteredArticles.length === 0 ? ( +
+

+ {showArchived ? 'Нема архивирани написи' : 'Нема написи'} +

+ {!showArchived && ( + + )} +
+ ) : ( +
+ {filteredArticles.map((article) => (
- {article.title} @@ -201,14 +332,41 @@ export function AdminDashboardComponent() { )}
- + {showArchived ? ( + + ) : ( + + )} +
@@ -219,42 +377,74 @@ export function AdminDashboardComponent() {
- {/* Quick Stats */} -
- - -
- {liveBlogs.filter(b => b.status === 'live').length || 0} -
-

Активни live блогови

-
-
- - -
- {articles.filter(a => a.status === 'published').length || 0} -
-

Објавени написи

-
-
- - -
- {liveBlogs.filter(b => b.isPinned).length || 0} -
-

Закачени live блогови

-
-
- - -
- {(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + - (articles.reduce((sum, a) => sum + a.views, 0) || 0)} -
-

Вкупно прегледи

-
-
-
-
- ); -} \ No newline at end of file + {/* Quick Stats - Only show when not viewing archived items */} + {!showArchived && ( +
+ + +
+ {liveBlogs.filter(b => b.status === 'live').length || 0} +
+

Активни live блогови

+
+
+ + +
+ {articles.filter(a => a.status === 'published').length || 0} +
+

Објавени написи

+
+
+ + +
+ {liveBlogs.filter(b => b.isPinned).length || 0} +
+

Закачени live блогови

+
+
+ + +
+ {(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + + (articles.reduce((sum, a) => sum + a.views, 0) || 0)} +
+

Вкупно прегледи

+
+
+
+ )} + + {/* Confirmation Dialog */} + {showConfirmDialog && itemToDelete && ( +
+
+

+ {dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'} +

+

+ {dialogType === 'delete' + ? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?` + : `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`} +

+
+ + +
+
+
+ )} +
+ ); + } \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a6a7f75..75fccae 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -50,12 +50,48 @@ export interface FindArticlesParams { category?: string; author?: string; tag?: string; - status?: 'draft' | 'published' | 'archived'; + status?: string; search?: string; page?: number; limit?: number; } +export interface CreateArticleDto { + title: string; + content: string; + excerpt?: string; + slug?: string; + featuredImage?: string; + tags?: string[]; + status?: 'draft' | 'published' | 'archived'; + strapiId?: string; + authorId?: string; + categoryId?: string; + imagePosition?: 'top' | 'left' | 'right' | 'none'; + imageSize?: 'small' | 'medium' | 'large'; + videoUrl?: string; + videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; + videoCaption?: string; +} + +export interface UpdateArticleDto { + title?: string; + content?: string; + excerpt?: string; + slug?: string; + featuredImage?: string; + tags?: string[]; + status?: 'draft' | 'published' | 'archived'; + strapiId?: string; + authorId?: string; + categoryId?: string; + imagePosition?: 'top' | 'left' | 'right' | 'none'; + imageSize?: 'small' | 'medium' | 'large'; + videoUrl?: string; + videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; + videoCaption?: string; +} + export async function fetchArticles(params: FindArticlesParams = {}): Promise { console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL); const searchParams = new URLSearchParams(); @@ -102,6 +138,63 @@ export async function fetchArticleById(id: string): Promise
{ return response.json(); } +export async function createArticle(dto: CreateArticleDto): Promise
{ + 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
{ + 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 { + 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
{ + 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
{ + const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, { + method: 'PATCH', + }); + if (!response.ok) { + throw new Error('Failed to publish article'); + } + return response.json(); +} + // Live Blog Types export interface LiveBlogUpdate { id: string; @@ -173,7 +266,7 @@ export interface LiveBlogUpdatesResponse { export interface FindLiveBlogsParams { category?: string; author?: string; - status?: 'draft' | 'live' | 'ended' | 'archived'; + status?: string; search?: string; page?: number; limit?: number; @@ -372,3 +465,23 @@ export async function deleteLiveBlog(id: string): Promise { throw new Error('Failed to delete live blog'); } } + +export async function archiveLiveBlog(id: string): Promise { + 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 { + 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(); +} diff --git a/frontend/src/queries/articles.ts b/frontend/src/queries/articles.ts index 3151992..f57d600 100644 --- a/frontend/src/queries/articles.ts +++ b/frontend/src/queries/articles.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import * as api from '../lib/api'; export function useArticles(params: api.FindArticlesParams = {}) { @@ -23,3 +23,68 @@ export function useArticleById(id: string) { enabled: !!id, }); } + +export function useCreateArticle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: api.createArticle, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + }, + }); +} + +export function useUpdateArticle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) => + api.updateArticle(id, dto), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + queryClient.invalidateQueries({ queryKey: ['article', variables.id] }); + queryClient.invalidateQueries({ queryKey: ['article', data.slug] }); + }, + }); +} + +export function useDeleteArticle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: api.deleteArticle, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + // Also invalidate any specific article queries + queryClient.removeQueries({ queryKey: ['article', variables] }); + }, + }); +} + +export function useArchiveArticle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: api.archiveArticle, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + queryClient.invalidateQueries({ queryKey: ['article', variables] }); + queryClient.invalidateQueries({ queryKey: ['article', data.slug] }); + }, + }); +} + +export function usePublishArticle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) => + api.publishArticle(id, status), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + queryClient.invalidateQueries({ queryKey: ['article', variables.id] }); + queryClient.invalidateQueries({ queryKey: ['article', data.slug] }); + }, + }); +} diff --git a/frontend/src/queries/live-blogs.ts b/frontend/src/queries/live-blogs.ts index e3df4de..dc8b7e7 100644 --- a/frontend/src/queries/live-blogs.ts +++ b/frontend/src/queries/live-blogs.ts @@ -155,4 +155,31 @@ export function useDeleteLiveBlog() { queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] }); }, }); +} + +export function useArchiveLiveBlog() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => api.archiveLiveBlog(id), + onSuccess: () => { + // Invalidate all live blogs queries + queryClient.invalidateQueries({ queryKey: ['liveBlogs'] }); + queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] }); + }, + }); +} + +export function usePublishLiveBlog() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) => + api.publishLiveBlog(id, status), + onSuccess: () => { + // Invalidate all live blogs queries + queryClient.invalidateQueries({ queryKey: ['liveBlogs'] }); + queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] }); + }, + }); } \ No newline at end of file diff --git a/todos.md b/todos.md index 88ad45e..f84b3a8 100644 --- a/todos.md +++ b/todos.md @@ -2,3 +2,9 @@ 2. [ ] video 3. social media integration [telegram, whatsup, facebook, viber] 4. share with functionality + +lets implement a role based auth +admin can create, edit and delete articles and liveblogs +admin can create contributors +contributors can create, edit and delete articles and liveblogs +user can react(like, dislike) to articles and write comments