diff --git a/frontend/src/components/home/HeroArticle.tsx b/frontend/src/components/home/HeroArticle.tsx index 3d8d989..8d3aa4e 100644 --- a/frontend/src/components/home/HeroArticle.tsx +++ b/frontend/src/components/home/HeroArticle.tsx @@ -131,7 +131,7 @@ export function HeroArticle() {
- {article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares} + {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)} shares
diff --git a/frontend/src/components/routes/AdminDashboardComponent.tsx b/frontend/src/components/routes/AdminDashboardComponent.tsx index ee53c41..a609fb4 100644 --- a/frontend/src/components/routes/AdminDashboardComponent.tsx +++ b/frontend/src/components/routes/AdminDashboardComponent.tsx @@ -4,13 +4,10 @@ import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, us 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<{ @@ -20,12 +17,12 @@ export function AdminDashboardComponent() { } | null>(null); const [isProcessing, setIsProcessing] = useState(false); const [showArchived, setShowArchived] = useState(false); - - const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ + + const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50, status: showArchived ? 'archived' : 'draft,live,ended' }); - const { data: articlesData, isLoading: loadingArticles } = useArticles({ + const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50, status: showArchived ? 'archived' : 'draft,published' }); @@ -36,13 +33,9 @@ export function AdminDashboardComponent() { const publishArticleMutation = usePublishArticle(); const publishLiveBlogMutation = usePublishLiveBlog(); const updateArticleMutation = useUpdateArticle(); - + 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 }); @@ -73,7 +66,7 @@ export function AdminDashboardComponent() { const handleConfirmAction = async () => { if (!itemToDelete) return; - + setIsProcessing(true); try { if (dialogType === 'delete') { @@ -82,7 +75,7 @@ export function AdminDashboardComponent() { } else { await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); } - } else { // archive + } else { if (itemToDelete.type === 'article') { await archiveArticleMutation.mutateAsync(itemToDelete.id); } else { @@ -121,14 +114,14 @@ export function AdminDashboardComponent() { switch (status) { case 'published': case 'live': - return 'bg-green-100 text-green-800 border-green-200'; + return 'bg-green-500 text-white border-2 border-foreground'; 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 'ended': - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'bg-gray-400 text-white border-2 border-foreground'; 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 ( -
-
-
-

Администраторски панел

-

- Управување со сите написи и live блогови -

+
+
+
+
+

Администраторски панел

+

+ Управување со сите написи и live блогови +

+
+
+ + + +
-
- - - -
+ {!showArchived && ( +
+
+
+ {liveBlogs.filter(b => b.status === 'live').length || 0} +
+

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

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

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

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

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

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

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

+
+
+ )} +
- {/* Live Blogs Section */} - - - - {showArchived ? 'Архивирани Live блогови' : 'Live блогови'} - - {filteredLiveBlogs.length || 0} - - - - Сите live блогови со статус и датум на креирање - - - +
+
+

+ {showArchived ? 'Архивирани Live блогови' : 'Live блогови'} +

+ + {liveBlogs.length || 0} + +
+
{loadingLiveBlogs ? (

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

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

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

- {!showArchived && ( - - )} -
- ) : ( -
- {filteredLiveBlogs.map((blog) => ( -
-
-
-
- - {blog.title} - - - {getStatusText(blog.status)} - - {blog.isPinned && ( - - Закачено - - )} -
-
- Слаг: {blog.slug} - - - Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })} + ) : liveBlogs.length === 0 ? ( +
+

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

+
+ ) : ( + liveBlogs.map((blog) => ( +
+
+
+
+ + {blog.title} + + + {getStatusText(blog.status)} + + {blog.isPinned && ( + + Закачено - - Прегледи: {blog.viewCount} -
+ )}
-
-
+
+ + + {showArchived ? ( + + ) : ( + + )} + +
+
+
+ )) + )} +
+
+ +
+
+

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

+ + {articles.length || 0} + +
+
+ {loadingArticles ? ( +
+
+

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

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

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

+
+ ) : ( + articles.map((article) => ( +
+
+
+
+ + {article.title} + + + {getStatusText(article.status)} + + {article.isHero && ( + + ★ Hero + + )} +
+
+ Слаг: {article.slug} + + {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })} + + Прегледи: {article.views} + + Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)} +
+ {article.excerpt && ( +

+ {article.excerpt} +

+ )} +
+
+
+ +
+
+ {showArchived ? ( - ) : ( - )} -
- ))} -
+
+ )) )} - - - - {/* Articles Section */} - - - - {showArchived ? 'Архивирани написи' : 'Написи'} - - {filteredArticles.length || 0} - - - - Сите написи со статус и датум на креирање - - - - {loadingArticles ? ( -
-
-

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

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

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

- {!showArchived && ( - - )} -
- ) : ( -
- {filteredArticles.map((article) => ( -
-
-
-
- - {article.title} - - - {getStatusText(article.status)} - - {article.isHero && ( - - ★ Hero - - )} -
-
- Слаг: {article.slug} - - - Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })} - - - Прегледи: {article.views} - - - Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + - (article.whatsappShares || 0) + (article.telegramShares || 0)} - -
- {article.excerpt && ( -

- {article.excerpt} -

- )} -
-
- - - - {showArchived ? ( - - ) : ( - - )} - -
-
-
- ))} -
- )} -
-
+
+
- {/* Quick Stats - Only show when not viewing archived items */} - {!showArchived && ( -
- - -
- {liveBlogs.filter(b => b.status === 'live').length || 0} -
-

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

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

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

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

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

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

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

-
-
-
- )} - - {/* Social Media Analytics - Only show when not viewing archived items */} - {!showArchived && ( -
- - - Social Media Analytics - Share statistics for articles - - -
- {/* Total Shares Summary */} -
- - -
- {articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0} -
-

Facebook Shares

-
-
- - -
- {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0} -
-

Twitter Shares

-
-
- - -
- {articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0} -
-

WhatsApp Shares

-
-
- - -
- {articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0} -
-

Telegram Shares

-
-
- - -
- {articles.reduce((sum, a) => - sum + (a.facebookShares || 0) + (a.twitterShares || 0) + - (a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0} -
-

Total Shares

-
-
-
- - {/* Top Shared Articles */} -
-

Top Shared Articles

-
- {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 ( -
-
-
-
- - {article.title} - -
-
- Views: {article.views} - - Shares: {totalShares} - - Share Rate: {shareRate}% -
-
- - - Facebook: {article.facebookShares || 0} - - - - Twitter: {article.twitterShares || 0} - - - - WhatsApp: {article.whatsappShares || 0} - - - - Telegram: {article.telegramShares || 0} - -
-
-
-
- ); - })} -
-
-
-
-
+ {!showArchived && ( +
+

Social Media Analytics

+
+
+
+ {articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0} +
+

Facebook Shares

+
+
+
+ {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0} +
+

Twitter Shares

+
+
+
+ {articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0} +
+

WhatsApp Shares

+
+
+
+ {articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0} +
+

Telegram Shares

+
+
+
+ {articles.reduce((sum, a) => + sum + (a.facebookShares || 0) + (a.twitterShares || 0) + + (a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0} +
+

Total Shares

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

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

-

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

-
- - -
-
-
- )} -
- ); - } \ No newline at end of file +
+

Top Shared Articles

+
+ {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 ( +
+
+
+ + {article.title} + +
+ Views: {article.views} + + Shares: {totalShares} + + Share Rate: {shareRate}% +
+
+ + + FB: {article.facebookShares || 0} + + + + X: {article.twitterShares || 0} + + + + WA: {article.whatsappShares || 0} + + + + TG: {article.telegramShares || 0} + +
+
+
+
+ ); + })} +
+
+
+ )} + + {showConfirmDialog && itemToDelete && ( +
+
+

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

+

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

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/seo/SocialMetaTags.tsx b/frontend/src/components/seo/SocialMetaTags.tsx index 5f32a2e..d9d2f2e 100644 --- a/frontend/src/components/seo/SocialMetaTags.tsx +++ b/frontend/src/components/seo/SocialMetaTags.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { Article } from '@/lib/api'; +import type { Article } from '@/lib/api'; interface SocialMetaTagsProps { article: Article; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f438ff9..516f831 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -73,6 +73,18 @@ export interface Article { }; createdAt: 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 { @@ -126,6 +138,7 @@ export interface UpdateArticleDto { videoUrl?: string; videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; videoCaption?: string; + isHero?: boolean; } export async function fetchArticles(params: FindArticlesParams = {}): Promise { @@ -620,6 +633,7 @@ export interface FindCommentsParams { articleId?: string; liveBlogId?: string; parentCommentId?: string; + parentId?: string; page?: number; limit?: number; } @@ -667,15 +681,20 @@ interface BackendComment { // Recursive function to map comment and its replies function mapBackendComment(comment: BackendComment): Comment { const mappedComment: Comment = { - ...comment, + id: comment.id, + content: comment.content, + articleId: comment.articleId, + liveBlogId: comment.liveBlogId, parentCommentId: comment.parentId, - // Ensure reactions object exists + userId: comment.userId, + isVisible: comment.isVisible, reactions: { likes: comment.likeCount || 0, dislikes: comment.dislikeCount || 0, }, - // Recursively map replies if they exist replies: comment.replies?.map(mapBackendComment) || [], + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, }; return mappedComment; } diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index e4309c6..f3a94c5 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -237,7 +237,7 @@ const articleDetailRoute = createRoute({ } if (article.tags && article.tags.length > 0) { - article.tags.forEach(tag => { + article.tags.forEach((tag: string) => { metaTags.push({ property: 'article:tag', content: tag }) }) }