450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
import { useState } from 'react';
|
||
import { useLiveBlogs } from '@/queries/live-blogs';
|
||
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';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { format } from 'date-fns';
|
||
import { mk } from 'date-fns/locale';
|
||
|
||
export function AdminDashboardComponent() {
|
||
// 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':
|
||
case 'live':
|
||
return 'bg-green-100 text-green-800 border-green-200';
|
||
case 'draft':
|
||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||
case 'archived':
|
||
case 'ended':
|
||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||
default:
|
||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status: string) => {
|
||
switch (status) {
|
||
case 'published': return 'Објавено';
|
||
case 'draft': return 'Нацрт';
|
||
case 'archived': return 'Архивирано';
|
||
case 'live': return 'Во живо';
|
||
case 'ended': return 'Завршено';
|
||
default: return status;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="py-8 space-y-8">
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
|
||
<p className="text-muted-foreground">
|
||
Управување со сите написи и live блогови
|
||
</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>
|
||
<Button asChild>
|
||
<Link to="/">Назад кон сајтот</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
{/* Live Blogs Section */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||
<Badge variant="outline" className="ml-2">
|
||
{filteredLiveBlogs.length || 0}
|
||
</Badge>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Сите live блогови со статус и датум на креирање
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loadingLiveBlogs ? (
|
||
<div className="text-center py-8">
|
||
<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>
|
||
) : filteredLiveBlogs.length === 0 ? (
|
||
<div className="text-center py-8 border rounded-lg">
|
||
<p className="text-muted-foreground">
|
||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||
</p>
|
||
{!showArchived && (
|
||
<Button asChild variant="outline" className="mt-4">
|
||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{filteredLiveBlogs.map((blog) => (
|
||
<div
|
||
key={blog.id}
|
||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Link
|
||
to="/admin/live-blogs/$slug"
|
||
params={{ slug: blog.slug }}
|
||
className="font-medium hover:text-primary hover:underline"
|
||
>
|
||
{blog.title}
|
||
</Link>
|
||
<Badge variant="outline" className={getStatusColor(blog.status)}>
|
||
{getStatusText(blog.status)}
|
||
</Badge>
|
||
{blog.isPinned && (
|
||
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||
Закачено
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||
<span>Слаг: {blog.slug}</span>
|
||
<span>•</span>
|
||
<span>
|
||
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||
</span>
|
||
<span>•</span>
|
||
<span>Прегледи: {blog.viewCount}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 ml-4">
|
||
<Button asChild size="sm" variant="outline">
|
||
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||
</Button>
|
||
<Button asChild size="sm" variant="ghost">
|
||
<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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Articles Section */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||
<Badge variant="outline" className="ml-2">
|
||
{filteredArticles.length || 0}
|
||
</Badge>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Сите написи со статус и датум на креирање
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loadingArticles ? (
|
||
<div className="text-center py-8">
|
||
<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>
|
||
) : filteredArticles.length === 0 ? (
|
||
<div className="text-center py-8 border rounded-lg">
|
||
<p className="text-muted-foreground">
|
||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||
</p>
|
||
{!showArchived && (
|
||
<Button asChild variant="outline" className="mt-4">
|
||
<Link to="/">Креирај нов напис</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{filteredArticles.map((article) => (
|
||
<div
|
||
key={article.id}
|
||
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Link
|
||
to="/articles/$id"
|
||
params={{ id: article.id }}
|
||
className="font-medium hover:text-primary hover:underline"
|
||
>
|
||
{article.title}
|
||
</Link>
|
||
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||
{getStatusText(article.status)}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||
<span>Слаг: {article.slug}</span>
|
||
<span>•</span>
|
||
<span>
|
||
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||
</span>
|
||
<span>•</span>
|
||
<span>Прегледи: {article.views}</span>
|
||
</div>
|
||
{article.excerpt && (
|
||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
|
||
{article.excerpt}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2 ml-4">
|
||
<Button asChild size="sm" variant="outline">
|
||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||
</Button>
|
||
<Button asChild size="sm" variant="ghost">
|
||
<Link to="/articles/$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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Quick Stats - Only show when not viewing archived items */}
|
||
{!showArchived && (
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="text-2xl font-bold">
|
||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="text-2xl font-bold">
|
||
{articles.filter(a => a.status === 'published').length || 0}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="text-2xl font-bold">
|
||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="text-2xl font-bold">
|
||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* Confirmation Dialog */}
|
||
{showConfirmDialog && itemToDelete && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-semibold mb-2">
|
||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||
</h3>
|
||
<p className="text-muted-foreground mb-4">
|
||
{dialogType === 'delete'
|
||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||
</p>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
|
||
Откажи
|
||
</Button>
|
||
<Button
|
||
variant={dialogType === 'delete' ? 'destructive' : 'default'}
|
||
onClick={handleConfirmAction}
|
||
disabled={isProcessing}
|
||
>
|
||
{isProcessing
|
||
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
||
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |