diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 639a4ff..4db4fce 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -6,6 +6,7 @@ import { IsUUID, IsNumber, IsBoolean, + IsDateString, } from 'class-validator'; import { ArticleStatus, @@ -371,6 +372,18 @@ export class CreateLiveBlogUpdateDto { @IsString() content: string; + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + authorId?: string; + + @IsOptional() + @IsDateString() + scheduledAt?: string; + @IsOptional() @IsString() image?: string; @@ -389,6 +402,14 @@ export class UpdateLiveBlogUpdateDto { @IsString() content?: string; + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + authorId?: string; + @IsOptional() @IsString() image?: string; @@ -400,8 +421,4 @@ export class UpdateLiveBlogUpdateDto { @IsOptional() @IsString() authorName?: string; - - @IsOptional() - @IsBoolean() - isPinned?: boolean; } diff --git a/frontend/src/components/ArticleTicker.tsx b/frontend/src/components/ArticleTicker.tsx index ea0a390..17de9eb 100644 --- a/frontend/src/components/ArticleTicker.tsx +++ b/frontend/src/components/ArticleTicker.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import * as api from '@/lib/api' +import { Zap } from 'lucide-react' export function ArticleTicker() { const { data } = useQuery({ @@ -13,37 +14,35 @@ export function ArticleTicker() { if (articles.length === 0) return null return ( -
-
-
- - Latest: - -
-
- {articles.map((article, index) => ( - - {article.title || 'No title'} - - ))} - {/* Duplicate for seamless scrolling */} - {articles.map((article, index) => ( - - {article.title || 'No title'} - - ))} -
+
+
+
+ + Топ вести +
+
+
+ {articles.map((article, index) => ( + + {article.title || 'No title'} + + ))} + {articles.map((article, index) => ( + + {article.title || 'No title'} + + ))}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/features/live-blog/LiveBlogTicker.tsx b/frontend/src/components/features/live-blog/LiveBlogTicker.tsx index db0d883..6ec3e98 100644 --- a/frontend/src/components/features/live-blog/LiveBlogTicker.tsx +++ b/frontend/src/components/features/live-blog/LiveBlogTicker.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Link } from '@tanstack/react-router'; import { useActiveLiveBlogs } from '@/queries/live-blogs'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Play, Pause, ChevronRight, Radio } from 'lucide-react'; @@ -9,7 +8,7 @@ interface LiveBlogTickerProps { className?: string; maxItems?: number; autoScroll?: boolean; - scrollSpeed?: number; // pixels per second + scrollSpeed?: number; } export function LiveBlogTicker({ @@ -25,7 +24,6 @@ export function LiveBlogTicker({ const contentRef = useRef(null); const animationRef = useRef(undefined); - // Calculate total width needed for scrolling const [totalWidth, setTotalWidth] = useState(0); useEffect(() => { @@ -35,7 +33,6 @@ export function LiveBlogTicker({ } }, [activeBlogs]); - // Auto-scroll animation useEffect(() => { if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) { if (animationRef.current) { @@ -53,7 +50,6 @@ export function LiveBlogTicker({ setScrollPosition(prev => { let newPos = prev + (scrollSpeed * delta) / 1000; - // Reset when scrolled past content if (newPos > totalWidth) { newPos = -(tickerRef.current?.offsetWidth || 0); } @@ -75,7 +71,7 @@ export function LiveBlogTicker({ if (isLoading) { return ( -
+
@@ -87,7 +83,7 @@ export function LiveBlogTicker({ } if (!activeBlogs || activeBlogs.length === 0) { - return null; // Don't show ticker if no active blogs + return null; } const displayBlogs = activeBlogs.slice(0, maxItems); @@ -97,56 +93,48 @@ export function LiveBlogTicker({ }; const handleBlogClick = () => { - // Reset scroll position when user interacts setScrollPosition(0); }; return ( -
- {/* Ticker header */} -
-
- - Live Now - - {activeBlogs.length} active - +
+
+
+ + LIVE + + {activeBlogs.length} +
{autoScroll && ( )} -
- {/* Ticker content */}
- {displayBlogs.map((blog, index) => ( + {displayBlogs.map((blog) => (
-
-
+
- + {blog.title} {blog.updates && blog.updates.length > 0 && ( - - {blog.updates.length} updates - + + {blog.updates.length} + )}
- - {/* Separator (except after last item) */} - {index < displayBlogs.length - 1 && ( -
- )}
))} - {/* Duplicate content for seamless looping */} - {displayBlogs.map((blog, index) => ( + {displayBlogs.map((blog) => (
-
-
+
- + {blog.title} {blog.updates && blog.updates.length > 0 && ( - - {blog.updates.length} updates - + + {blog.updates.length} + )}
- - {index < displayBlogs.length - 1 && ( -
- )}
))}
- {/* Progress indicator */} {autoScroll && totalWidth > 0 && (
-
+
); -} \ No newline at end of file +} diff --git a/frontend/src/components/features/live-blog/LiveBlogViewer.tsx b/frontend/src/components/features/live-blog/LiveBlogViewer.tsx index 49cf066..c099305 100644 --- a/frontend/src/components/features/live-blog/LiveBlogViewer.tsx +++ b/frontend/src/components/features/live-blog/LiveBlogViewer.tsx @@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
{updates.length === 0 && !updatesLoading ? ( diff --git a/frontend/src/components/home/HeroArticle.tsx b/frontend/src/components/home/HeroArticle.tsx index c675de0..8d3aa4e 100644 --- a/frontend/src/components/home/HeroArticle.tsx +++ b/frontend/src/components/home/HeroArticle.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { fetchHeroArticle } from '@/lib/api'; import { Button } from '@/components/ui/button'; -import { Calendar, User, ArrowRight } from 'lucide-react'; +import { Calendar, User, ArrowRight, Eye } from 'lucide-react'; export function HeroArticle() { const { data: article, isLoading, error } = useQuery({ @@ -12,23 +12,24 @@ export function HeroArticle() { if (isLoading) { return ( -
-
-
-
-
-
-
+
+
+
+
+
+
+
+
); } if (error) { return ( -
-
Error loading hero article
-
); @@ -36,115 +37,105 @@ export function HeroArticle() { if (!article) { return ( -
-
- - - - - - - +
+
+ ?
-

No Hero Article Set

-

+

NO HERO ARTICLE

+

Mark an article as "Hero" in the admin panel to feature it here.

-
- This space will showcase your most important story. +
+ This space will showcase your most important story
); } return ( -
- {/* Featured Image */} +
{article.featuredImage && ( -
- {article.title} -
-
- - Featured Story - +
+
+ + Прекршени Вести + +
+
+ {article.title} +
)} - {/* Content */}
-

+

{article.title}

- {/* Meta Information */} -
-
- +
+
+ {new Date(article.createdAt).toLocaleDateString('mk-MK', { day: 'numeric', - month: 'long', + month: 'short', year: 'numeric', })}
{article.author && ( -
- +
+ {article.author.name}
)} -
+
+ {article.views} views
- {/* Excerpt */} {article.excerpt && ( -

+

{article.excerpt}

)} - {/* Tags */} {article.tags && article.tags.length > 0 && (
{article.tags.map((tag) => ( - {tag} + #{tag} ))}
)} - {/* Read More Button */} -
+
- - {/* Social shares count */} -
- - {article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares} +
+ + {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)} shares
-
+
); -} \ No newline at end of file +} diff --git a/frontend/src/components/home/LatestArticlesGrid.tsx b/frontend/src/components/home/LatestArticlesGrid.tsx index 4a432f8..8cf0d41 100644 --- a/frontend/src/components/home/LatestArticlesGrid.tsx +++ b/frontend/src/components/home/LatestArticlesGrid.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import * as api from '@/lib/api' import { SocialShareButtons } from '@/components/features/social-share' +import { ArrowRight } from 'lucide-react' export function LatestArticlesGrid() { const { data, isLoading, error } = useQuery({ @@ -11,17 +12,13 @@ export function LatestArticlesGrid() { if (isLoading) { return ( -
+
{Array.from({ length: 12 }).map((_, i) => ( -
-
-
+
+
+
-
-
-
-
-
+
))}
@@ -30,9 +27,9 @@ export function LatestArticlesGrid() { if (error) { return ( -
-
Грешка при вчитување на статии
-

Обидете се повторно

+
+
ГРЕШКА
+

Обидете се повторно

) } @@ -41,107 +38,99 @@ export function LatestArticlesGrid() { if (articles.length === 0) { return ( -
-
Нема објавени статии
-

Проверете подоцна

+
+
НЕМА СТАТИИ
+

Проверете подоцна

) } return ( -
-
-

Најнови статии

+
+
+

Најнови

- Види сите - - - - + Сите +
-
- {articles.map((article) => ( -
+ {articles.map((article, index) => ( +
{article.featuredImage ? ( -
+
{article.title} -
+
) : ( -
- - - - - - +
+
+ N
)} -

- {article.title} -

- - {article.excerpt && ( -

- {article.excerpt} -

- )} +
+

+ {article.title} +

+ + {article.excerpt && ( +

+ {article.excerpt} +

+ )} +
-
-
-
- - {new Date(article.createdAt).toLocaleDateString('mk-MK', { - day: 'numeric', - month: 'short', - year: 'numeric', - })} - - - {article.views} прегледи -
+
+
+ + {new Date(article.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + })} + {article.category && ( {article.category.name} )}
- +
+ +
-
+
))}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx b/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx index 4374d7a..bd6f74d 100644 --- a/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx +++ b/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { fetchPinnedLiveBlogs } from '@/lib/api'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react'; @@ -15,25 +14,27 @@ export function PinnedLiveBlogsSidebar() { switch (status) { case 'live': return ( - + LIVE - + ); case 'ended': return ( - + ENDED - + ); case 'archived': return ( - + ARCHIVED - + ); default: return ( - DRAFT + + DRAFT + ); } }; @@ -48,10 +49,10 @@ export function PinnedLiveBlogsSidebar() { if (isLoading) { return ( -
+
- -

Pinned Live Blogs

+ +

Pinned Live

{[1, 2, 3].map((i) => ( @@ -67,14 +68,14 @@ export function PinnedLiveBlogsSidebar() { if (error) { return ( -
+
- -

Pinned Live Blogs

+ +

Pinned Live

-
Error loading live blogs
-
@@ -84,15 +85,15 @@ export function PinnedLiveBlogsSidebar() { if (!liveBlogs || liveBlogs.length === 0) { return ( -
+
- -

Pinned Live Blogs

+ +

Pinned Live

-
-
No pinned live blogs
-

- Pin live blogs from the admin panel to feature them here. +

+
No pinned live blogs
+

+ Pin live blogs from the admin panel.

@@ -100,44 +101,40 @@ export function PinnedLiveBlogsSidebar() { } return ( -
- {/* Header */} -
+
+
- -

Pinned Live Blogs

+ +

Pinned Live

- - {liveBlogs.length} pinned - + + {liveBlogs.length} +
- {/* Live Blogs List */}
{liveBlogs.map((liveBlog) => ( -
- {/* Title and Status */} +
-

+

{liveBlog.title}

{getStatusBadge(liveBlog.status)}
- {/* Description */} {liveBlog.description && ( -

+

{liveBlog.description}

)} - {/* Meta Information */} -
+
{formatDate(liveBlog.createdAt)} @@ -145,33 +142,31 @@ export function PinnedLiveBlogsSidebar() {
- {liveBlog.viewCount} views + {liveBlog.viewCount}
{liveBlog.updates && liveBlog.updates.length > 0 && (
- {liveBlog.updates.length} updates + {liveBlog.updates.length}
)} {liveBlog.author && (
- by {liveBlog.author.name} + {liveBlog.author.name}
)}
- {/* Featured Image (small) */} {liveBlog.featuredImage && (
-
+
{liveBlog.title} -
)} @@ -180,14 +175,13 @@ export function PinnedLiveBlogsSidebar() { ))}
- {/* View All Link */} -
+
-
); -} \ No newline at end of file +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index cfd6aa7..fc1e996 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,10 +1,17 @@ - import { useState } from 'react'; import { Link } from '@tanstack/react-router'; import { useAuth } from '../../contexts/AuthContext'; import { Button } from '../ui/button'; import { ThemeToggle } from './ThemeToggle'; -import { Menu, X } from 'lucide-react'; +import { Menu, X, Zap } from 'lucide-react'; + +const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември']; +const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела']; + +const formatDateMk = () => { + const d = new Date(); + return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`; +}; export function Header() { const { user, logout, isAuthenticated, hasRole } = useAuth(); @@ -24,29 +31,54 @@ export function Header() { { to: '/art', label: 'Уметност' }, { to: '/science', label: 'Наука' }, { to: '/archive', label: 'Архива' }, - { to: '/live-blogs', label: 'Live' }, + { to: '/live-blogs', label: 'LIVE' }, ]; const adminLinks = [ { to: '/admin', label: 'Admin' }, - { to: '/admin/live-blogs/create', label: '+ New Live Blog' }, + { to: '/admin/live-blogs/create', label: '+ New Live' }, ]; return ( -
+
+
+
+
+
+ + Сатирични вести од Македонија +
+
+ {formatDateMk()} +
+
+
+
+
-

- Placebo.mk -

+ +

+ P + l + a + c + e + b + o + . + m + k +

+ - {/* Desktop Navigation */} - +
+ +
+
- {/* Mobile Menu Button */}
- {/* Mobile Navigation */} {isMobileMenuOpen && ( -
-
- {navLinks.map((link) => ( +
+
+ {navLinks.map((link, index) => ( {link.label} @@ -128,13 +160,13 @@ export function Header() { {isAuthenticated ? ( <> {(hasRole('admin') || hasRole('contributor')) && ( -
-

Admin

+
+

Admin

{adminLinks.map((link) => ( {link.label} @@ -143,21 +175,20 @@ export function Header() {
)} -
-
- - Logged in as: {user?.username} +
+
+ + {user?.username}
@@ -165,10 +196,10 @@ export function Header() { ) : ( - Login / Register + Login )}
@@ -177,4 +208,4 @@ export function Header() {
); -} \ No newline at end of file +} 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/routes/LiveBlogsComponent.tsx b/frontend/src/components/routes/LiveBlogsComponent.tsx index 9845194..c32dc17 100644 --- a/frontend/src/components/routes/LiveBlogsComponent.tsx +++ b/frontend/src/components/routes/LiveBlogsComponent.tsx @@ -37,10 +37,11 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => ( + key={liveBlog.id} + to="/live-blogs/$slug" + params={{ slug: liveBlog.slug }} + className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" + >
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/components/ui/button-variants.ts b/frontend/src/components/ui/button-variants.ts index be181f9..04d622c 100644 --- a/frontend/src/components/ui/button-variants.ts +++ b/frontend/src/components/ui/button-variants.ts @@ -1,25 +1,25 @@ import { cva } from "class-variance-authority" export const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", + brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm", + brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent", + brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent", }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-11 px-6 py-2", + sm: "h-9 px-4 py-1", + lg: "h-14 px-8 text-base", + icon: "h-12 w-12", }, }, defaultVariants: { @@ -27,4 +27,4 @@ export const buttonVariants = cva( size: "default", }, } -) \ No newline at end of file +) diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index c1da9be..745dfe9 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -8,7 +8,7 @@ const Card = React.forwardRef<
(({ className, ...props }, ref) => (
)) @@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<

(({ className, ...props }, ref) => (

)) @@ -69,7 +69,7 @@ const CardFooter = React.forwardRef< >(({ className, ...props }, ref) => (

)) diff --git a/frontend/src/index.css b/frontend/src/index.css index 43e5ceb..aa9df23 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,51 +1,51 @@ -/* @tailwind base; */ -/* @tailwind components; */ +@tailwind base; +@tailwind components; @tailwind utilities; @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --background: 35 20% 95%; + --foreground: 0 0% 4%; + --card: 35 20% 95%; + --card-foreground: 0 0% 4%; + --popover: 35 20% 95%; + --popover-foreground: 0 0% 4%; + --primary: 0 0% 4%; + --primary-foreground: 35 20% 95%; + --secondary: 35 15% 88%; + --secondary-foreground: 0 0% 4%; + --muted: 35 10% 90%; + --muted-foreground: 0 0% 40%; + --accent: 70 100% 50%; + --accent-foreground: 0 0% 4%; + --destructive: 0 80% 50%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 4%; + --input: 0 0% 4%; + --ring: 70 100% 50%; + --radius: 0px; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + --background: 0 0% 8%; + --foreground: 35 20% 95%; + --card: 0 0% 12%; + --card-foreground: 35 20% 95%; + --popover: 0 0% 12%; + --popover-foreground: 35 20% 95%; + --primary: 35 20% 95%; + --primary-foreground: 0 0% 8%; + --secondary: 0 0% 15%; + --secondary-foreground: 35 20% 95%; + --muted: 0 0% 15%; + --muted-foreground: 0 0% 60%; + --accent: 70 100% 50%; + --accent-foreground: 0 0% 8%; + --destructive: 0 80% 50%; + --destructive-foreground: 0 0% 98%; + --border: 35 20% 95%; + --input: 35 20% 95%; + --ring: 70 100% 50%; } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b36a87d..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 { @@ -360,8 +373,12 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise< return response.json(); } -export async function fetchLiveBlogBySlug(slug: string): Promise { - const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`); +export async function fetchLiveBlogBySlug(slugOrId: string): Promise { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId); + const endpoint = isUuid + ? `${API_BASE_URL}/live-blogs/${slugOrId}` + : `${API_BASE_URL}/live-blogs/slug/${slugOrId}`; + const response = await authFetch(endpoint); if (!response.ok) { throw new Error('Failed to fetch live blog'); } @@ -616,6 +633,7 @@ export interface FindCommentsParams { articleId?: string; liveBlogId?: string; parentCommentId?: string; + parentId?: string; page?: number; limit?: number; } @@ -663,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 2644475..f3a94c5 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -11,19 +11,20 @@ import { AuthPage } from './components/routes/AuthPage' import { SportComponent } from './components/routes/SportComponent' import { ArtComponent } from './components/routes/ArtComponent' import { ScienceComponent } from './components/routes/ScienceComponent' -import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { ProtectedRoute } from './components/auth/ProtectedRoute' import { Header } from './components/layout/Header' import { HeroArticle } from './components/home/HeroArticle' import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar' import { LatestArticlesGrid } from './components/home/LatestArticlesGrid' +import { Button } from './components/ui/button' +import { Zap, Search, Users } from 'lucide-react' import './styles.css' const rootRoute = createRootRoute({ head: () => ({ meta: [ { - title: 'Placebo.mk - Sarcastic News from Macedonia', + title: 'Placebo.mk - Сатирични вести од Македонија', description: 'Latest news and articles from Macedonia with a sarcastic twist', }, ], @@ -31,14 +32,48 @@ const rootRoute = createRootRoute({ component: () => (
+
-