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

View File

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

View File

@ -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<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(
strapiId: string,
data: Partial<CreateArticleDto>,
@ -129,7 +143,9 @@ export class ArticlesService {
}
async removeByStrapiId(strapiId: string): Promise<void> {
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`);

View File

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

View File

@ -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<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
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,7 +451,9 @@ export class LiveBlogService implements OnModuleInit {
}
async removeByStrapiId(strapiId: string): Promise<void> {
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`);
@ -451,7 +461,9 @@ export class LiveBlogService implements OnModuleInit {
}
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

View File

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

View File

@ -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;
@ -111,7 +117,9 @@ export class StrapiService {
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;
@ -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,10 +201,17 @@ export class StrapiService {
async syncSingleArticle(
strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}, event: ${event}`);
this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>(
@ -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,11 +259,15 @@ 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 {
@ -254,7 +276,9 @@ export class StrapiService {
};
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:`,
@ -297,10 +321,12 @@ export class StrapiService {
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,10 +348,17 @@ export class StrapiService {
async syncSingleLiveBlog(
strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`);
this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
@ -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,11 +400,15 @@ 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 {
@ -379,7 +417,9 @@ export class StrapiService {
};
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<void> {
this.logger.log(
@ -421,7 +466,9 @@ 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);

View File

@ -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,12 +10,98 @@ 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) {
case 'published':
@ -50,6 +138,12 @@ export function AdminDashboardComponent() {
</p>
</div>
<div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
@ -64,9 +158,9 @@ export function AdminDashboardComponent() {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Live блогови</span>
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
<Badge variant="outline" className="ml-2">
{liveBlogs.length || 0}
{filteredLiveBlogs.length || 0}
</Badge>
</CardTitle>
<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>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : liveBlogs.length === 0 ? (
) : filteredLiveBlogs.length === 0 ? (
<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">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{liveBlogs.map((blog) => (
{filteredLiveBlogs.map((blog) => (
<div
key={blog.id}
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 items-center gap-2 mb-1">
<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"
>
{blog.title}
@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to={`/admin/live-blogs/${blog.slug}` as any}>Уреди</Link>
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
Преглед
</Link>
</Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
@ -143,9 +269,9 @@ export function AdminDashboardComponent() {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Написи</span>
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
<Badge variant="outline" className="ml-2">
{articles.length || 0}
{filteredArticles.length || 0}
</Badge>
</CardTitle>
<CardDescription>
@ -158,16 +284,20 @@ export function AdminDashboardComponent() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : articles.length === 0 ? (
) : filteredArticles.length === 0 ? (
<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">
<Link to="/">Креирај нов напис</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{articles.map((article) => (
{filteredArticles.map((article) => (
<div
key={article.id}
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 items-center gap-2 mb-1">
<Link
to={`/articles/${article.id}` as any}
to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline"
>
{article.title}
@ -202,13 +333,40 @@ export function AdminDashboardComponent() {
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to={`/articles/${article.id}` as any}>Уреди</Link>
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to={`/articles/${article.id}` as any} target="_blank">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
Преглед
</Link>
</Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
@ -219,7 +377,8 @@ export function AdminDashboardComponent() {
</Card>
</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">
<Card>
<CardContent className="pt-6">
@ -255,6 +414,37 @@ export function AdminDashboardComponent() {
</CardContent>
</Card>
</div>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3>
<p className="text-muted-foreground mb-4">
{dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи
</Button>
<Button
variant={dialogType === 'delete' ? 'destructive' : 'default'}
onClick={handleConfirmAction}
disabled={isProcessing}
>
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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<ArticlesResponse> {
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<Article> {
return response.json();
}
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create article');
}
return response.json();
}
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update article');
}
return response.json();
}
export async function deleteArticle(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete article');
}
}
export async function archiveArticle(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive article');
}
return response.json();
}
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish article');
}
return response.json();
}
// Live Blog Types
export interface LiveBlogUpdate {
id: string;
@ -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<void> {
throw new Error('Failed to delete live blog');
}
}
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive live blog');
}
return response.json();
}
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish live blog');
}
return response.json();
}

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

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