551 lines
25 KiB
TypeScript
551 lines
25 KiB
TypeScript
import { useState } from 'react';
|
|
import { useLiveBlogs } from '@/queries/live-blogs';
|
|
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
|
|
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { Button } from '@/components/ui/button';
|
|
import { format } from 'date-fns';
|
|
import { mk } from 'date-fns/locale';
|
|
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
|
|
|
|
export function AdminDashboardComponent() {
|
|
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 updateArticleMutation = useUpdateArticle();
|
|
|
|
const liveBlogs = liveBlogsData?.data || [];
|
|
const articles = articlesData?.data || [];
|
|
|
|
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 {
|
|
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 handleSetHero = async (articleId: string, isHero: boolean) => {
|
|
setIsProcessing(true);
|
|
try {
|
|
await updateArticleMutation.mutateAsync({
|
|
id: articleId,
|
|
dto: { isHero }
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update hero status:', error);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'published':
|
|
case 'live':
|
|
return 'bg-green-500 text-white border-2 border-foreground';
|
|
case 'draft':
|
|
return 'bg-yellow-400 text-black border-2 border-foreground';
|
|
case 'archived':
|
|
case 'ended':
|
|
return 'bg-gray-400 text-white border-2 border-foreground';
|
|
default:
|
|
return 'bg-blue-500 text-white border-2 border-foreground';
|
|
}
|
|
};
|
|
|
|
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="space-y-8">
|
|
<div className="border-b-4 border-foreground pb-6">
|
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
|
|
<p className="font-body text-muted-foreground mt-1">
|
|
Управување со сите написи и live блогови
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3 flex-wrap">
|
|
<Button
|
|
variant={showArchived ? 'brutal' : 'brutalOutline'}
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
>
|
|
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
|
</Button>
|
|
<Button asChild variant="brutalAccent">
|
|
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
|
</Button>
|
|
<Button asChild variant="brutalOutline">
|
|
<Link to="/">Назад кон сајтот</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!showArchived && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="border-brutal bg-card p-5">
|
|
<div className="text-4xl font-display">
|
|
{liveBlogs.filter((b) => b.status === 'live').length || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
|
Активни live блогови
|
|
</p>
|
|
</div>
|
|
<div className="border-brutal bg-card p-5">
|
|
<div className="text-4xl font-display">
|
|
{articles.filter((a) => a.status === 'published').length || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
|
Објавени написи
|
|
</p>
|
|
</div>
|
|
<div className="border-brutal bg-card p-5">
|
|
<div className="text-4xl font-display">
|
|
{liveBlogs.filter((b) => b.isPinned).length || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
|
Закачени live блогови
|
|
</p>
|
|
</div>
|
|
<div className="border-brutal bg-card p-5">
|
|
<div className="text-4xl font-display">
|
|
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
|
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
|
Вкупни прегледи
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!showArchived && <PushNotificationManager />}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
<div className="border-brutal bg-card">
|
|
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
|
<h2 className="text-2xl font-display uppercase">
|
|
{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
|
|
</h2>
|
|
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
|
{liveBlogs.length || 0}
|
|
</span>
|
|
</div>
|
|
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
|
{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>
|
|
) : liveBlogs.length === 0 ? (
|
|
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
|
<p className="text-muted-foreground">
|
|
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
liveBlogs.map((blog) => (
|
|
<div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<Link
|
|
to="/admin/live-blogs/$slug"
|
|
params={{ slug: blog.slug }}
|
|
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
|
>
|
|
{blog.title}
|
|
</Link>
|
|
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
|
|
{getStatusText(blog.status)}
|
|
</span>
|
|
{blog.isPinned && (
|
|
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
|
Закачено
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
|
<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-1 flex-shrink-0">
|
|
<Button asChild size="sm" variant="brutalOutline">
|
|
<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="brutalOutline"
|
|
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
|
disabled={isProcessing}
|
|
>
|
|
Објави
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="brutalOutline"
|
|
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 className="border-brutal bg-card">
|
|
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
|
<h2 className="text-2xl font-display uppercase">
|
|
{showArchived ? 'Архивирани написи' : 'Написи'}
|
|
</h2>
|
|
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
|
{articles.length || 0}
|
|
</span>
|
|
</div>
|
|
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
|
{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>
|
|
) : articles.length === 0 ? (
|
|
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
|
<p className="text-muted-foreground">
|
|
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
articles.map((article) => (
|
|
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<Link
|
|
to="/articles/$id"
|
|
params={{ id: article.id }}
|
|
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
|
>
|
|
{article.title}
|
|
</Link>
|
|
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
|
|
{getStatusText(article.status)}
|
|
</span>
|
|
{article.isHero && (
|
|
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
|
★ Hero
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
|
<span>Слаг: {article.slug}</span>
|
|
<span>•</span>
|
|
<span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
|
<span>•</span>
|
|
<span>Прегледи: {article.views}</span>
|
|
<span>•</span>
|
|
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
|
|
</div>
|
|
{article.excerpt && (
|
|
<p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
|
|
{article.excerpt}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 flex-shrink-0 flex-col">
|
|
<div className="flex gap-1">
|
|
<Button asChild size="sm" variant="brutalOutline">
|
|
<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>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant={article.isHero ? 'brutal' : 'brutalOutline'}
|
|
onClick={() => handleSetHero(article.id, !article.isHero)}
|
|
disabled={isProcessing || updateArticleMutation.isPending}
|
|
>
|
|
{article.isHero ? '★ Hero' : 'Set Hero'}
|
|
</Button>
|
|
{showArchived ? (
|
|
<Button
|
|
size="sm"
|
|
variant="brutalOutline"
|
|
onClick={() => handlePublishClick('article', article.id)}
|
|
disabled={isProcessing}
|
|
>
|
|
Објави
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="brutalOutline"
|
|
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>
|
|
</div>
|
|
|
|
{!showArchived && (
|
|
<div className="border-brutal bg-card p-6">
|
|
<h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
|
<div className="border-brutal-sm bg-background p-4">
|
|
<div className="text-3xl font-display">
|
|
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
|
|
</div>
|
|
<div className="border-brutal-sm bg-background p-4">
|
|
<div className="text-3xl font-display">
|
|
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
|
|
</div>
|
|
<div className="border-brutal-sm bg-background p-4">
|
|
<div className="text-3xl font-display">
|
|
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
|
|
</div>
|
|
<div className="border-brutal-sm bg-background p-4">
|
|
<div className="text-3xl font-display">
|
|
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
|
|
</div>
|
|
<div className="border-brutal-sm bg-background p-4">
|
|
<div className="text-3xl font-display">
|
|
{articles.reduce((sum, a) =>
|
|
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
|
|
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
|
|
</div>
|
|
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
|
|
<div className="space-y-3">
|
|
{articles
|
|
.filter(a => a.status === 'published')
|
|
.sort((a, b) => {
|
|
const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
|
|
(a.whatsappShares || 0) + (a.telegramShares || 0);
|
|
const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
|
|
(b.whatsappShares || 0) + (b.telegramShares || 0);
|
|
return bShares - aShares;
|
|
})
|
|
.slice(0, 5)
|
|
.map((article) => {
|
|
const totalShares = (article.facebookShares || 0) +
|
|
(article.twitterShares || 0) +
|
|
(article.whatsappShares || 0) +
|
|
(article.telegramShares || 0);
|
|
const shareRate = article.views > 0
|
|
? ((totalShares / article.views) * 100).toFixed(2)
|
|
: '0.00';
|
|
|
|
return (
|
|
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
to="/articles/$id"
|
|
params={{ id: article.id }}
|
|
className="font-display text-lg hover:underline hover:text-accent-foreground"
|
|
>
|
|
{article.title}
|
|
</Link>
|
|
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
|
|
<span>Views: {article.views}</span>
|
|
<span>•</span>
|
|
<span>Shares: {totalShares}</span>
|
|
<span>•</span>
|
|
<span>Share Rate: {shareRate}%</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-2 text-xs flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-blue-600"></span>
|
|
FB: {article.facebookShares || 0}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-sky-500"></span>
|
|
X: {article.twitterShares || 0}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-green-500"></span>
|
|
WA: {article.whatsappShares || 0}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-cyan-500"></span>
|
|
TG: {article.telegramShares || 0}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showConfirmDialog && itemToDelete && (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
<div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
|
|
<h3 className="text-2xl font-display uppercase mb-3">
|
|
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
|
</h3>
|
|
<p className="font-body text-muted-foreground mb-6">
|
|
{dialogType === 'delete'
|
|
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
|
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
|
|
Откажи
|
|
</Button>
|
|
<Button
|
|
variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
|
|
onClick={handleConfirmAction}
|
|
disabled={isProcessing}
|
|
>
|
|
{isProcessing
|
|
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
|
|
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|