admin dashboard fixed

maybe we need another design for this page
This commit is contained in:
echo 2026-02-16 20:42:11 +01:00
parent aa79eba06d
commit 71b1b549c3
5 changed files with 397 additions and 455 deletions

View File

@ -131,7 +131,7 @@ export function HeroArticle() {
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground"> <div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
<span className="font-bold text-foreground"> <span className="font-bold text-foreground">
{article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares} {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
</span> shares </span> shares
</div> </div>
</div> </div>

View File

@ -4,13 +4,10 @@ import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, us
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs'; import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
// State for confirmation dialog and filters
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete'); const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{ const [itemToDelete, setItemToDelete] = useState<{
@ -20,12 +17,12 @@ export function AdminDashboardComponent() {
} | null>(null); } | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
limit: 50, limit: 50,
status: showArchived ? 'archived' : 'draft,live,ended' status: showArchived ? 'archived' : 'draft,live,ended'
}); });
const { data: articlesData, isLoading: loadingArticles } = useArticles({ const { data: articlesData, isLoading: loadingArticles } = useArticles({
limit: 50, limit: 50,
status: showArchived ? 'archived' : 'draft,published' status: showArchived ? 'archived' : 'draft,published'
}); });
@ -36,13 +33,9 @@ export function AdminDashboardComponent() {
const publishArticleMutation = usePublishArticle(); const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog(); const publishLiveBlogMutation = usePublishLiveBlog();
const updateArticleMutation = useUpdateArticle(); const updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => { const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title }); setItemToDelete({ type, id, title });
@ -73,7 +66,7 @@ export function AdminDashboardComponent() {
const handleConfirmAction = async () => { const handleConfirmAction = async () => {
if (!itemToDelete) return; if (!itemToDelete) return;
setIsProcessing(true); setIsProcessing(true);
try { try {
if (dialogType === 'delete') { if (dialogType === 'delete') {
@ -82,7 +75,7 @@ export function AdminDashboardComponent() {
} else { } else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
} }
} else { // archive } else {
if (itemToDelete.type === 'article') { if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id); await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else { } else {
@ -121,14 +114,14 @@ export function AdminDashboardComponent() {
switch (status) { switch (status) {
case 'published': case 'published':
case 'live': case 'live':
return 'bg-green-100 text-green-800 border-green-200'; return 'bg-green-500 text-white border-2 border-foreground';
case 'draft': case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200'; return 'bg-yellow-400 text-black border-2 border-foreground';
case 'archived': case 'archived':
case 'ended': case 'ended':
return 'bg-gray-100 text-gray-800 border-gray-200'; return 'bg-gray-400 text-white border-2 border-foreground';
default: default:
return 'bg-blue-100 text-blue-800 border-blue-200'; return 'bg-blue-500 text-white border-2 border-foreground';
} }
}; };
@ -144,129 +137,255 @@ export function AdminDashboardComponent() {
}; };
return ( return (
<div className="py-8 space-y-8"> <div className="space-y-8">
<div className="flex justify-between items-center"> <div className="border-b-4 border-foreground pb-6">
<div> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1> <div>
<p className="text-muted-foreground"> <h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
Управување со сите написи и live блогови <p className="font-body text-muted-foreground mt-1">
</p> Управување со сите написи и 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>
<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>
{!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>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Live Blogs Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredLiveBlogs.length || 0} {liveBlogs.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите live блогови со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingLiveBlogs ? ( {loadingLiveBlogs ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredLiveBlogs.length === 0 ? ( ) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'} {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p> </p>
{!showArchived && ( </div>
<Button asChild variant="outline" className="mt-4"> ) : (
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link> liveBlogs.map((blog) => (
</Button> <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> <div className="flex-1 min-w-0">
) : ( <div className="flex items-center gap-2 mb-2 flex-wrap">
<div className="space-y-4"> <Link
{filteredLiveBlogs.map((blog) => ( to="/admin/live-blogs/$slug"
<div params={{ slug: blog.slug }}
key={blog.id} className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" >
> {blog.title}
<div className="flex items-start justify-between"> </Link>
<div className="flex-1"> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
<div className="flex items-center gap-2 mb-1"> {getStatusText(blog.status)}
<Link </span>
to="/admin/live-blogs/$slug" {blog.isPinned && (
params={{ slug: blog.slug }} <span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
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> )}
<span>Прегледи: {blog.viewCount}</span>
</div>
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<Button asChild size="sm" variant="outline"> <span>Слаг: {blog.slug}</span>
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link> <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>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank"> <Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
Преглед </Button>
</Link> </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> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('liveBlog', blog.id)} onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing} disabled={isProcessing}
> >
Објави Објави
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)} onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing} disabled={isProcessing}
> >
Архивирај Архивирај
</Button> </Button>
)} )}
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)} onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing} disabled={isProcessing}
> >
Избриши Избриши
@ -274,343 +393,147 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))} </div>
</div> ))
)} )}
</CardContent> </div>
</Card> </div>
{/* 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>
{article.isHero && (
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
Hero
</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>
<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 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>
<Button
size="sm"
variant={article.isHero ? "default" : "outline"}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set as Hero'}
</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> </div>
{/* Quick Stats - Only show when not viewing archived items */} {!showArchived && (
{!showArchived && ( <div className="border-brutal bg-card p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
<Card> <div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<CardContent className="pt-6"> <div className="border-brutal-sm bg-background p-4">
<div className="text-2xl font-bold"> <div className="text-3xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0} {articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
</CardContent> </div>
</Card> <div className="border-brutal-sm bg-background p-4">
<Card> <div className="text-3xl font-display">
<CardContent className="pt-6"> {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
<div className="text-2xl font-bold"> </div>
{articles.filter(a => a.status === 'published').length || 0} <p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
</div> </div>
<p className="text-sm text-muted-foreground">Објавени написи</p> <div className="border-brutal-sm bg-background p-4">
</CardContent> <div className="text-3xl font-display">
</Card> {articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
<Card> </div>
<CardContent className="pt-6"> <p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
<div className="text-2xl font-bold"> </div>
{liveBlogs.filter(b => b.isPinned).length || 0} <div className="border-brutal-sm bg-background p-4">
</div> <div className="text-3xl font-display">
<p className="text-sm text-muted-foreground">Закачени live блогови</p> {articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</CardContent> </div>
</Card> <p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
<Card> </div>
<CardContent className="pt-6"> <div className="border-brutal-sm bg-background p-4">
<div className="text-2xl font-bold"> <div className="text-3xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + {articles.reduce((sum, a) =>
(articles.reduce((sum, a) => sum + a.views, 0) || 0)} sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
</div> (a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
<p className="text-sm text-muted-foreground">Вкупно прегледи</p> </div>
</CardContent> <p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
</Card> </div>
</div>
)}
{/* Social Media Analytics - Only show when not viewing archived items */}
{!showArchived && (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle>Social Media Analytics</CardTitle>
<CardDescription>Share statistics for articles</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Total Shares Summary */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Facebook Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Twitter Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">WhatsApp Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Telegram Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) =>
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Total Shares</p>
</CardContent>
</Card>
</div>
{/* Top Shared Articles */}
<div>
<h3 className="text-lg font-semibold 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 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>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<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">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
Facebook: {article.facebookShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-sky-500 rounded-full"></span>
Twitter: {article.twitterShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
WhatsApp: {article.whatsappShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-cyan-500 rounded-full"></span>
Telegram: {article.telegramShares || 0}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
</div> </div>
)}
{/* Confirmation Dialog */} <div>
{showConfirmDialog && itemToDelete && ( <h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="space-y-3">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> {articles
<h3 className="text-lg font-semibold mb-2"> .filter(a => a.status === 'published')
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'} .sort((a, b) => {
</h3> const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
<p className="text-muted-foreground mb-4"> (a.whatsappShares || 0) + (a.telegramShares || 0);
{dialogType === 'delete' const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?` (b.whatsappShares || 0) + (b.telegramShares || 0);
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`} return bShares - aShares;
</p> })
<div className="flex justify-end gap-2"> .slice(0, 5)
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}> .map((article) => {
Откажи const totalShares = (article.facebookShares || 0) +
</Button> (article.twitterShares || 0) +
<Button (article.whatsappShares || 0) +
variant={dialogType === 'delete' ? 'destructive' : 'default'} (article.telegramShares || 0);
onClick={handleConfirmAction} const shareRate = article.views > 0
disabled={isProcessing} ? ((totalShares / article.views) * 100).toFixed(2)
> : '0.00';
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...') return (
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')} <div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
</Button> <div className="flex items-start justify-between gap-3">
</div> <div className="flex-1 min-w-0">
</div> <Link
</div> to="/articles/$id"
)} params={{ id: article.id }}
</div> 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>
);
}

View File

@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { Article } from '@/lib/api'; import type { Article } from '@/lib/api';
interface SocialMetaTagsProps { interface SocialMetaTagsProps {
article: Article; article: Article;

View File

@ -73,6 +73,18 @@ export interface Article {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isHero?: boolean;
isPinned?: boolean;
facebookShares?: number;
twitterShares?: number;
whatsappShares?: number;
telegramShares?: number;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
twitterTitle?: string;
twitterDescription?: string;
twitterImage?: string;
} }
export interface ArticlesResponse { export interface ArticlesResponse {
@ -126,6 +138,7 @@ export interface UpdateArticleDto {
videoUrl?: string; videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string; videoCaption?: string;
isHero?: boolean;
} }
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
@ -620,6 +633,7 @@ export interface FindCommentsParams {
articleId?: string; articleId?: string;
liveBlogId?: string; liveBlogId?: string;
parentCommentId?: string; parentCommentId?: string;
parentId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@ -667,15 +681,20 @@ interface BackendComment {
// Recursive function to map comment and its replies // Recursive function to map comment and its replies
function mapBackendComment(comment: BackendComment): Comment { function mapBackendComment(comment: BackendComment): Comment {
const mappedComment: Comment = { const mappedComment: Comment = {
...comment, id: comment.id,
content: comment.content,
articleId: comment.articleId,
liveBlogId: comment.liveBlogId,
parentCommentId: comment.parentId, parentCommentId: comment.parentId,
// Ensure reactions object exists userId: comment.userId,
isVisible: comment.isVisible,
reactions: { reactions: {
likes: comment.likeCount || 0, likes: comment.likeCount || 0,
dislikes: comment.dislikeCount || 0, dislikes: comment.dislikeCount || 0,
}, },
// Recursively map replies if they exist
replies: comment.replies?.map(mapBackendComment) || [], replies: comment.replies?.map(mapBackendComment) || [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}; };
return mappedComment; return mappedComment;
} }

View File

@ -237,7 +237,7 @@ const articleDetailRoute = createRoute({
} }
if (article.tags && article.tags.length > 0) { if (article.tags && article.tags.length > 0) {
article.tags.forEach(tag => { article.tags.forEach((tag: string) => {
metaTags.push({ property: 'article:tag', content: tag }) metaTags.push({ property: 'article:tag', content: tag })
}) })
} }