diff --git a/backend/src/modules/articles.controller.ts b/backend/src/modules/articles.controller.ts index 4162aa1..17094c4 100644 --- a/backend/src/modules/articles.controller.ts +++ b/backend/src/modules/articles.controller.ts @@ -40,6 +40,12 @@ export class ArticlesController { return this.articlesService.findAll(dto); } + @Get('hero') + @Public() + findHero() { + return this.articlesService.findHeroArticle(); + } + @Get(':id') @Public() findOne(@Param('id') id: string) { diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 1d20179..639a4ff 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -6,7 +6,6 @@ import { IsUUID, IsNumber, IsBoolean, - IsDate, } from 'class-validator'; import { ArticleStatus, @@ -48,6 +47,10 @@ export class CreateArticleDto { @IsString() strapiId?: string; + @IsOptional() + @IsBoolean() + isHero?: boolean; + @IsOptional() @IsUUID() authorId?: string; @@ -135,6 +138,10 @@ export class UpdateArticleDto { @IsString() strapiId?: string; + @IsOptional() + @IsBoolean() + isHero?: boolean; + @IsOptional() @IsUUID() authorId?: string; @@ -217,6 +224,35 @@ export class FindArticlesDto { limit?: number; } +export class FindLiveBlogsDto { + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + author?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + page?: number; + + @IsOptional() + limit?: number; +} + export class CreateLiveBlogDto { @IsString() title: string; @@ -336,20 +372,16 @@ export class CreateLiveBlogUpdateDto { content: string; @IsOptional() - @IsBoolean() - isPinned?: boolean; - - @IsOptional() - @IsUUID() - authorId?: string; - - @IsOptional() - @IsDate() - scheduledAt?: Date; + @IsString() + image?: string; @IsOptional() @IsString() - strapiId?: string; + videoUrl?: string; + + @IsOptional() + @IsString() + authorName?: string; } export class UpdateLiveBlogUpdateDto { @@ -358,47 +390,18 @@ export class UpdateLiveBlogUpdateDto { content?: string; @IsOptional() - @IsBoolean() - isPinned?: boolean; - - @IsOptional() - @IsUUID() - authorId?: string; - - @IsOptional() - @IsDate() - scheduledAt?: Date; + @IsString() + image?: string; @IsOptional() @IsString() - strapiId?: string; -} - -export class FindLiveBlogsDto { - @IsOptional() - @IsString() - category?: string; + videoUrl?: string; @IsOptional() @IsString() - author?: string; - - @IsOptional() - @IsString() - status?: string; + authorName?: string; @IsOptional() @IsBoolean() isPinned?: boolean; - - @IsOptional() - @IsString() - search?: string; - - @IsOptional() - @IsNumber() - page?: number; - - @IsOptional() - limit?: number; } diff --git a/backend/src/modules/articles.service.ts b/backend/src/modules/articles.service.ts index 2b92bba..583d5c8 100644 --- a/backend/src/modules/articles.service.ts +++ b/backend/src/modules/articles.service.ts @@ -293,4 +293,15 @@ export class ArticlesService { await this.articleRepository.remove(article); this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); } + + async findHeroArticle(): Promise
{ + return this.articleRepository.findOne({ + where: { + isHero: true, + status: ArticleStatus.PUBLISHED, + }, + relations: ['author', 'category'], + order: { createdAt: 'DESC' }, + }); + } } diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index d24cae5..f5a4a8c 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -254,6 +254,9 @@ export class Article { @Column({ default: 0 }) telegramShares: number; + @Column({ default: false }) + isHero: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/frontend/src/components/home/HeroArticle.tsx b/frontend/src/components/home/HeroArticle.tsx new file mode 100644 index 0000000..c675de0 --- /dev/null +++ b/frontend/src/components/home/HeroArticle.tsx @@ -0,0 +1,150 @@ +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'; + +export function HeroArticle() { + const { data: article, isLoading, error } = useQuery({ + queryKey: ['hero-article'], + queryFn: fetchHeroArticle, + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
Error loading hero article
+ +
+ ); + } + + if (!article) { + return ( +
+
+ + + + + + + +
+

No Hero Article Set

+

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

+
+ This space will showcase your most important story. +
+
+ ); + } + + return ( +
+ {/* Featured Image */} + {article.featuredImage && ( +
+ {article.title} +
+
+ + Featured Story + +
+
+ )} + + {/* Content */} +
+

+ {article.title} +

+ + {/* Meta Information */} +
+
+ + + {new Date(article.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'long', + 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} + + ))} +
+ )} + + {/* Read More Button */} +
+ + + + + {/* Social shares count */} +
+ + {article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares} + shares +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx b/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx new file mode 100644 index 0000000..4374d7a --- /dev/null +++ b/frontend/src/components/home/PinnedLiveBlogsSidebar.tsx @@ -0,0 +1,193 @@ +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'; + +export function PinnedLiveBlogsSidebar() { + const { data: liveBlogs, isLoading, error } = useQuery({ + queryKey: ['pinned-live-blogs'], + queryFn: fetchPinnedLiveBlogs, + }); + + const getStatusBadge = (status: string) => { + switch (status) { + case 'live': + return ( + + LIVE + + ); + case 'ended': + return ( + + ENDED + + ); + case 'archived': + return ( + + ARCHIVED + + ); + default: + return ( + DRAFT + ); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + }); + }; + + if (isLoading) { + return ( +
+
+ +

Pinned Live Blogs

+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+
+ +

Pinned Live Blogs

+
+
+
Error loading live blogs
+ +
+
+ ); + } + + if (!liveBlogs || liveBlogs.length === 0) { + return ( +
+
+ +

Pinned Live Blogs

+
+
+
No pinned live blogs
+

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

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Pinned Live Blogs

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

+ {liveBlog.title} +

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

+ {liveBlog.description} +

+ )} + + {/* Meta Information */} +
+
+ + {formatDate(liveBlog.createdAt)} +
+ +
+ + {liveBlog.viewCount} views +
+ + {liveBlog.updates && liveBlog.updates.length > 0 && ( +
+ + {liveBlog.updates.length} updates +
+ )} + + {liveBlog.author && ( +
+ by {liveBlog.author.name} +
+ )} +
+ + {/* Featured Image (small) */} + {liveBlog.featuredImage && ( +
+
+ {liveBlog.title} +
+
+
+ )} +
+ + ))} +
+ + {/* View All Link */} +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/routes/AdminDashboardComponent.tsx b/frontend/src/components/routes/AdminDashboardComponent.tsx index e2ba2aa..ee53c41 100644 --- a/frontend/src/components/routes/AdminDashboardComponent.tsx +++ b/frontend/src/components/routes/AdminDashboardComponent.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useLiveBlogs } from '@/queries/live-blogs'; -import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles'; +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'; @@ -35,6 +35,7 @@ export function AdminDashboardComponent() { const archiveLiveBlogMutation = useArchiveLiveBlog(); const publishArticleMutation = usePublishArticle(); const publishLiveBlogMutation = usePublishLiveBlog(); + const updateArticleMutation = useUpdateArticle(); const liveBlogs = liveBlogsData?.data || []; const articles = articlesData?.data || []; @@ -102,6 +103,20 @@ export function AdminDashboardComponent() { 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': @@ -304,18 +319,23 @@ export function AdminDashboardComponent() { >
-
- - {article.title} - - - {getStatusText(article.status)} - -
+
+ + {article.title} + + + {getStatusText(article.status)} + + {article.isHero && ( + + ★ Hero + + )} +
Слаг: {article.slug} @@ -336,43 +356,51 @@ export function AdminDashboardComponent() {

)}
-
- - - {showArchived ? ( - - ) : ( - - )} - -
+
+ + + + {showArchived ? ( + + ) : ( + + )} + +
))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b7c756b..6156803 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -398,6 +398,15 @@ export async function fetchRecentLiveBlogs(): Promise { return response.json(); } +export async function fetchHeroArticle(): Promise
{ + const response = await authFetch(`${API_BASE_URL}/articles/hero`); + if (!response.ok) { + throw new Error('Failed to fetch hero article'); + } + const article = await response.json(); + return article || null; +} + export async function fetchPinnedLiveBlogs(): Promise { const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`); if (!response.ok) { diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index d9ed3a8..56ec215 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -9,9 +9,10 @@ import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogCompo import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AuthPage } from './components/routes/AuthPage' import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' -import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' 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 './styles.css' const rootRoute = createRootRoute({ @@ -53,131 +54,112 @@ const indexRoute = createRoute({
-
- {/* Main content - 3 columns */} -
- {/* Hero section */} -
-
- - - - - - - -
-

- Placebo.mk -

-

- Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction. -

-
- - {/* Features grid */} -
-
-
- - - - - - -
-

Latest Articles

-

- Freshly brewed sarcasm on current events, politics, and everything in between. -

-
- -
-
- - - - - -
-

No Filter

-

- We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary. -

-
- -
-
- - - - - - -
-

Live Coverage

-

- Real-time updates on breaking news with our live blogging system. No delays, just facts. -

-
-
- - {/* Call to action */} -
-

Ready for some unfiltered truth?

-

- Dive into our articles or follow live coverage of breaking news as it happens. -

-
- - Read Articles - - - - - - - View Live Blogs - - - - - -
+ {/* Hero Section with Pinned Live Blogs Sidebar */} +
+ {/* Hero Article - 2/3 width */} +
+ +
+ + {/* Pinned Live Blogs Sidebar - 1/3 width */} +
+ +
+
+ + {/* Brand Introduction */} +
+
+ + + + + + + +
+

+ Placebo.mk +

+

+ Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction. +

+
+ + {/* Features grid */} +
+
+
+ + + + + +
+

Latest Articles

+

+ Freshly brewed sarcasm on current events, politics, and everything in between. +

- {/* Sidebar - 1 column */} -
- - - {/* Additional sidebar content */} -
-

About Placebo.mk

-

- We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm. -

-
-
-
- Live coverage updated in real-time -
-
-
- No ads, no sponsors, no BS -
-
-
- 100% independent journalism -
-
+
+
+ + + + +
+

No Filter

+

+ We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary. +

+
+ +
+
+ + + + + + +
+

Live Coverage

+

+ Real-time updates on breaking news with our live blogging system. No delays, just facts. +

+
+
+ + {/* Call to action */} +
+

Ready for some unfiltered truth?

+

+ Dive into our articles or follow live coverage of breaking news as it happens. +

+
+ + Read Articles + + + + + + + View Live Blogs + + + + +
diff --git a/todos.md b/todos.md index 61a79ab..9e2a4a4 100644 --- a/todos.md +++ b/todos.md @@ -1,4 +1,6 @@ -3. social media integration [telegram, whatsup, facebook, viber] -4. share with functionality 5. admin dashboard enhancement +6. homepage: + lets add hero section on the home page, next to the hero section [2/3 width] place pinned + live blogs. in the hero section we will show latest article with tag: hero. + make apropriate changes to database schema and backend as needed.