From 4ccb65ba884f9c6615f8d46433bab54c449f41cb Mon Sep 17 00:00:00 2001 From: echo Date: Thu, 29 Jan 2026 06:06:54 +0100 Subject: [PATCH] pin to homepage and liveblog ticker --- backend/src/modules/articles.dto.ts | 16 +- backend/src/modules/entities.ts | 3 + backend/src/modules/live-blog.controller.ts | 21 +- backend/src/modules/live-blog.service.ts | 150 ++++++----- .../admin/live-blog/LiveBlogManager.tsx | 69 ++++- .../features/live-blog/LiveBlogTicker.tsx | 238 ++++++++++++++++++ .../live-blog/PinnedLiveBlogSidebar.tsx | 199 +++++++++++++++ .../components/routes/LiveBlogsComponent.tsx | 5 +- frontend/src/lib/api.ts | 16 ++ frontend/src/queries/live-blogs.ts | 18 +- frontend/src/routes.tsx | 204 +++++++++------ 11 files changed, 782 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/features/live-blog/LiveBlogTicker.tsx create mode 100644 frontend/src/components/features/live-blog/PinnedLiveBlogSidebar.tsx diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 988c0a1..be12635 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -138,6 +138,10 @@ export class CreateLiveBlogDto { @IsEnum(LiveBlogStatus) status?: LiveBlogStatus; + @IsOptional() + @IsBoolean() + isPinned?: boolean; + @IsOptional() @IsString() strapiId?: string; @@ -168,6 +172,10 @@ export class UpdateLiveBlogDto { @IsEnum(LiveBlogStatus) status?: LiveBlogStatus; + @IsOptional() + @IsBoolean() + isPinned?: boolean; + @IsOptional() @IsString() strapiId?: string; @@ -234,8 +242,12 @@ export class FindLiveBlogsDto { author?: string; @IsOptional() - @IsEnum(LiveBlogStatus) - status?: LiveBlogStatus; + @IsString() + status?: string; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; @IsOptional() @IsString() diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index 5febf09..0769b76 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -174,6 +174,9 @@ export class LiveBlog { }) status: LiveBlogStatus; + @Column({ default: false }) + isPinned: boolean; + @Column({ nullable: true }) strapiId: string; diff --git a/backend/src/modules/live-blog.controller.ts b/backend/src/modules/live-blog.controller.ts index c71f563..d0e52a5 100644 --- a/backend/src/modules/live-blog.controller.ts +++ b/backend/src/modules/live-blog.controller.ts @@ -29,6 +29,22 @@ export class LiveBlogController { constructor(private readonly liveBlogService: LiveBlogService) {} // Live Blog CRUD operations + @Get('featured') + getFeatured() { + this.logger.log('GET /featured called'); + return this.liveBlogService.findPinned(); + } + + @Get('active') + getActive() { + return this.liveBlogService.findActive(); + } + + @Get('recent') + getRecent() { + return this.liveBlogService.getLiveBlogsWithRecentUpdates(); + } + @Post() create( @Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto, @@ -43,11 +59,6 @@ export class LiveBlogController { return this.liveBlogService.findAll(dto); } - @Get('recent') - getRecent() { - return this.liveBlogService.getLiveBlogsWithRecentUpdates(); - } - @Get(':id') findOne(@Param('id') id: string) { return this.liveBlogService.findOne(id); diff --git a/backend/src/modules/live-blog.service.ts b/backend/src/modules/live-blog.service.ts index 1e0d1d5..5e79868 100644 --- a/backend/src/modules/live-blog.service.ts +++ b/backend/src/modules/live-blog.service.ts @@ -5,7 +5,7 @@ import { OnModuleInit, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, In } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Response } from 'express'; import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities'; @@ -96,7 +96,8 @@ export class LiveBlogService implements OnModuleInit { const { category, author, - status = LiveBlogStatus.LIVE, + status, + isPinned, search, page = 1, limit = 10, @@ -105,8 +106,23 @@ export class LiveBlogService implements OnModuleInit { const queryBuilder = this.liveBlogRepository .createQueryBuilder('liveBlog') .leftJoinAndSelect('liveBlog.author', 'author') - .leftJoinAndSelect('liveBlog.category', 'category') - .where('liveBlog.status = :status', { status }); + .leftJoinAndSelect('liveBlog.category', 'category'); + + // Handle status filter - can be single value or comma-separated list + if (status) { + if (typeof status === 'string' && status.includes(',')) { + const statuses = status.split(',').map((s) => s.trim()); + queryBuilder.where('liveBlog.status IN (:...statuses)', { statuses }); + } else { + queryBuilder.where('liveBlog.status = :status', { status }); + } + } else if (!isPinned) { + // Default to live blogs if no status specified AND not querying for pinned blogs + // Pinned blogs can be either live or ended + queryBuilder.where('liveBlog.status = :status', { + status: LiveBlogStatus.LIVE, + }); + } if (category) { queryBuilder.andWhere('category.slug = :category', { category }); @@ -123,6 +139,10 @@ export class LiveBlogService implements OnModuleInit { ); } + if (isPinned !== undefined) { + queryBuilder.andWhere('liveBlog.isPinned = :isPinned', { isPinned }); + } + const [data, total] = await queryBuilder .orderBy('liveBlog.createdAt', 'DESC') .skip((page - 1) * limit) @@ -178,82 +198,63 @@ export class LiveBlogService implements OnModuleInit { } async update(id: string, dto: UpdateLiveBlogDto): Promise { - // Build SET clause for update - const setClauses: string[] = []; - const params: any[] = []; - + const liveBlog = await this.liveBlogRepository.findOne({ + where: { id }, + relations: ['author', 'category'], + }); + + if (!liveBlog) { + throw new NotFoundException(`Live blog with ID ${id} not found`); + } + + // Track if status changed for event emission + const oldStatus = liveBlog.status; + + // Update fields if (dto.title !== undefined) { - setClauses.push('title = ?'); - params.push(dto.title); + liveBlog.title = dto.title; } - + if (dto.slug !== undefined) { - setClauses.push('slug = ?'); - params.push(dto.slug); + liveBlog.slug = dto.slug; } - + if (dto.description !== undefined) { - setClauses.push('description = ?'); - params.push(dto.description); + liveBlog.description = dto.description; } - + if (dto.status !== undefined) { - setClauses.push('status = ?'); - params.push(dto.status); + liveBlog.status = dto.status; } - + if (dto.strapiId !== undefined) { - setClauses.push('strapiId = ?'); - params.push(dto.strapiId); + liveBlog.strapiId = dto.strapiId; } - + if (dto.authorId !== undefined) { - setClauses.push('authorId = ?'); - params.push(dto.authorId); + liveBlog.authorId = dto.authorId; } - + if (dto.categoryId !== undefined) { - setClauses.push('categoryId = ?'); - params.push(dto.categoryId); + liveBlog.categoryId = dto.categoryId; } - - // Always update updatedAt - setClauses.push('updatedAt = CURRENT_TIMESTAMP'); - - if (setClauses.length === 0) { - // Nothing to update - return this.findOneWithoutIncrement(id); + + if (dto.isPinned !== undefined) { + liveBlog.isPinned = dto.isPinned; } - - // Add id to params - params.push(id); - - // Execute raw SQL update - const queryRunner = this.liveBlogRepository.manager.connection.createQueryRunner(); - await queryRunner.connect(); - - try { - await queryRunner.query( - `UPDATE live_blogs SET ${setClauses.join(', ')} WHERE id = ?`, - params - ); - - // Emit status change event if status changed - if (dto.status) { - const currentBlog = await this.findOneWithoutIncrement(id); - if (currentBlog && dto.status !== currentBlog.status) { - this.eventEmitter.emit('live-blog.status-change', { - blogId: id, - status: dto.status, - }); - } - } - - // Return the updated entity - return this.findOneWithoutIncrement(id); - } finally { - await queryRunner.release(); + + // Save the updated entity + const updatedBlog = await this.liveBlogRepository.save(liveBlog); + + // Emit status change event if status changed + if (dto.status !== undefined && dto.status !== oldStatus) { + this.eventEmitter.emit('live-blog.status-change', { + blogId: id, + status: dto.status, + }); } + + return updatedBlog; } async remove(id: string): Promise { @@ -444,4 +445,25 @@ export class LiveBlogService implements OnModuleInit { .orderBy('updates.createdAt', 'DESC') .getMany(); } + + async findPinned(): Promise { + return this.liveBlogRepository.find({ + where: { + isPinned: true, + status: In([LiveBlogStatus.LIVE, LiveBlogStatus.ENDED]), // Show both live and ended pinned blogs + }, + relations: ['author', 'category', 'updates'], + order: { updatedAt: 'DESC' }, + take: 5, + }); + } + + async findActive(): Promise { + return this.liveBlogRepository.find({ + where: { status: LiveBlogStatus.LIVE }, + relations: ['author', 'category'], + order: { createdAt: 'DESC' }, + take: 20, + }); + } } diff --git a/frontend/src/components/admin/live-blog/LiveBlogManager.tsx b/frontend/src/components/admin/live-blog/LiveBlogManager.tsx index 39ae406..d298d8c 100644 --- a/frontend/src/components/admin/live-blog/LiveBlogManager.tsx +++ b/frontend/src/components/admin/live-blog/LiveBlogManager.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useLiveBlog, useDeleteLiveBlogUpdate, @@ -23,7 +23,9 @@ import { Square, Archive, Eye, - MessageSquare + MessageSquare, + Pin, + PinOff } from 'lucide-react'; interface LiveBlogManagerProps { @@ -37,7 +39,7 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp const [isEditing, setIsEditing] = useState(false); const [editForm, setEditForm] = useState({}); - const { data: liveBlog, isLoading, error, refetch } = useLiveBlog(slug); + const { data: liveBlog, isLoading, error } = useLiveBlog(slug); const deleteUpdateMutation = useDeleteLiveBlogUpdate(); const updateLiveBlogMutation = useUpdateLiveBlog(); const deleteLiveBlogMutation = useDeleteLiveBlog(); @@ -111,6 +113,21 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp } }; + const handleTogglePin = async () => { + if (!liveBlog) return; + + try { + await updateLiveBlogMutation.mutateAsync({ + id: liveBlog.id, + dto: { isPinned: !liveBlog.isPinned }, + }); + + } catch (error) { + console.error('Failed to toggle pin:', error); + alert(`Failed to toggle pin: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + const handleDeleteLiveBlog = async () => { if (!liveBlog) return; @@ -402,6 +419,25 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp {updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'} )} + + @@ -422,15 +458,24 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp {liveBlog.description} )} -
- Status: - -
- {getStatusIcon(liveBlog.status)} - {liveBlog.status} -
-
-
+
+ Status: + +
+ {getStatusIcon(liveBlog.status)} + {liveBlog.status} +
+
+
+
+ Pinned: + +
+ {liveBlog.isPinned ? : } + {liveBlog.isPinned ? 'Yes' : 'No'} +
+
+
diff --git a/frontend/src/components/features/live-blog/LiveBlogTicker.tsx b/frontend/src/components/features/live-blog/LiveBlogTicker.tsx new file mode 100644 index 0000000..9dbd17c --- /dev/null +++ b/frontend/src/components/features/live-blog/LiveBlogTicker.tsx @@ -0,0 +1,238 @@ +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'; + +interface LiveBlogTickerProps { + className?: string; + maxItems?: number; + autoScroll?: boolean; + scrollSpeed?: number; // pixels per second +} + +export function LiveBlogTicker({ + className = '', + maxItems = 10, + autoScroll = true, + scrollSpeed = 30 +}: LiveBlogTickerProps) { + const { data: activeBlogs, isLoading } = useActiveLiveBlogs(); + const [isPaused, setIsPaused] = useState(false); + const [scrollPosition, setScrollPosition] = useState(0); + const tickerRef = useRef(null); + const contentRef = useRef(null); + const animationRef = useRef(); + + // Calculate total width needed for scrolling + const [totalWidth, setTotalWidth] = useState(0); + + useEffect(() => { + if (contentRef.current) { + const width = contentRef.current.scrollWidth; + setTotalWidth(width); + } + }, [activeBlogs]); + + // Auto-scroll animation + useEffect(() => { + if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + return; + } + + let lastTime: number; + const animate = (time: number) => { + if (!lastTime) lastTime = time; + const delta = time - lastTime; + lastTime = time; + + setScrollPosition(prev => { + let newPos = prev + (scrollSpeed * delta) / 1000; + + // Reset when scrolled past content + if (newPos > totalWidth) { + newPos = -tickerRef.current?.offsetWidth || 0; + } + + return newPos; + }); + + animationRef.current = requestAnimationFrame(animate); + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [autoScroll, isPaused, activeBlogs, scrollSpeed, totalWidth]); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (!activeBlogs || activeBlogs.length === 0) { + return null; // Don't show ticker if no active blogs + } + + const displayBlogs = activeBlogs.slice(0, maxItems); + + const handlePauseToggle = () => { + setIsPaused(!isPaused); + }; + + const handleBlogClick = () => { + // Reset scroll position when user interacts + setScrollPosition(0); + }; + + return ( +
+ {/* Ticker header */} +
+
+ + Live Now + + {activeBlogs.length} active + +
+ +
+ {autoScroll && ( + + )} + + + + +
+
+ + {/* Ticker content */} +
+
+ {displayBlogs.map((blog, index) => ( + + +
+
+
+
+
+ + {blog.title} + + {blog.updates && blog.updates.length > 0 && ( + + {blog.updates.length} updates + + )} +
+ + + {/* Separator (except after last item) */} + {index < displayBlogs.length - 1 && ( +
+ )} +
+ ))} + + {/* Duplicate content for seamless looping */} + {displayBlogs.map((blog, index) => ( + + +
+
+
+
+
+ + {blog.title} + + {blog.updates && blog.updates.length > 0 && ( + + {blog.updates.length} updates + + )} +
+ + + {index < displayBlogs.length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* Progress indicator */} + {autoScroll && totalWidth > 0 && ( +
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/features/live-blog/PinnedLiveBlogSidebar.tsx b/frontend/src/components/features/live-blog/PinnedLiveBlogSidebar.tsx new file mode 100644 index 0000000..106d835 --- /dev/null +++ b/frontend/src/components/features/live-blog/PinnedLiveBlogSidebar.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { Link } from '@tanstack/react-router'; +import { usePinnedLiveBlogs } from '@/queries/live-blogs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Clock, + MessageSquare, + Eye, + Pin, + Play, + Square, + ChevronRight +} from 'lucide-react'; + +interface PinnedLiveBlogSidebarProps { + className?: string; + maxItems?: number; +} + +export function PinnedLiveBlogSidebar({ + className = '', + maxItems = 3 +}: PinnedLiveBlogSidebarProps) { + const { data: pinnedBlogs, isLoading, error } = usePinnedLiveBlogs(); + + if (isLoading) { + return ( + + + + + Pinned Live Blogs + + + +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error || !pinnedBlogs || pinnedBlogs.length === 0) { + return null; // Don't show sidebar if no pinned blogs + } + + const displayBlogs = pinnedBlogs.slice(0, maxItems); + + const getStatusColor = (status: string) => { + switch (status) { + case 'live': return 'bg-green-100 text-green-800 border-green-200'; + case 'ended': return 'bg-red-100 text-red-800 border-red-200'; + default: return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'live': return ; + case 'ended': return ; + default: return null; + } + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 60) { + return `${diffMins}m ago`; + } else if (diffMins < 1440) { + return `${Math.floor(diffMins / 60)}h ago`; + } else { + return `${Math.floor(diffMins / 1440)}d ago`; + } + }; + + const getLatestUpdate = (blog: LiveBlog) => { + if (!blog.updates || blog.updates.length === 0) return null; + return blog.updates[blog.updates.length - 1]; + }; + + return ( + + +
+ + + Live Coverage + + + {pinnedBlogs.length} pinned + +
+
+ + +
+ {displayBlogs.map((blog) => { + const latestUpdate = getLatestUpdate(blog); + + return ( + +
+ {/* Header */} +
+
+

+ {blog.title} +

+ + {blog.description && ( +

+ {blog.description} +

+ )} +
+ + +
+ {getStatusIcon(blog.status)} + {blog.status} +
+
+
+ + {/* Latest update preview */} + {latestUpdate && ( +
+
+ + {formatTime(latestUpdate.createdAt)} +
+

{latestUpdate.content}

+
+ )} + + {/* Stats */} +
+
+
+ + {blog.viewCount} +
+ +
+ + {blog.updates?.length || 0} +
+
+ +
+ View live + +
+
+
+ + ); + })} +
+ + {pinnedBlogs.length > maxItems && ( +
+ + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/routes/LiveBlogsComponent.tsx b/frontend/src/components/routes/LiveBlogsComponent.tsx index 46b7400..0777e12 100644 --- a/frontend/src/components/routes/LiveBlogsComponent.tsx +++ b/frontend/src/components/routes/LiveBlogsComponent.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import * as api from '@/lib/api' +import { LiveBlogTicker } from '@/components/features/live-blog/LiveBlogTicker' export function LiveBlogsComponent() { const { data, isLoading, error } = useQuery({ @@ -25,7 +26,9 @@ export function LiveBlogsComponent() { } return ( -
+
+ +

Live Blogs

Breaking news and live updates

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 89c1b9b..905e6dd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -258,6 +258,22 @@ export async function fetchRecentLiveBlogs(): Promise { return response.json(); } +export async function fetchPinnedLiveBlogs(): Promise { + const response = await fetch(`${API_BASE_URL}/live-blogs/featured`); + if (!response.ok) { + throw new Error('Failed to fetch pinned live blogs'); + } + return response.json(); +} + +export async function fetchActiveLiveBlogs(): Promise { + const response = await fetch(`${API_BASE_URL}/live-blogs/active`); + if (!response.ok) { + throw new Error('Failed to fetch active live blogs'); + } + return response.json(); +} + // Admin functions export async function createLiveBlogUpdate( liveBlogId: string, diff --git a/frontend/src/queries/live-blogs.ts b/frontend/src/queries/live-blogs.ts index 10e5061..095a747 100644 --- a/frontend/src/queries/live-blogs.ts +++ b/frontend/src/queries/live-blogs.ts @@ -43,6 +43,22 @@ export function useRecentLiveBlogs() { }); } +export function usePinnedLiveBlogs() { + return useQuery({ + queryKey: ['pinnedLiveBlogs'], + queryFn: () => api.fetchPinnedLiveBlogs(), + refetchInterval: 30000, // Refetch every 30 seconds + }); +} + +export function useActiveLiveBlogs() { + return useQuery({ + queryKey: ['activeLiveBlogs'], + queryFn: () => api.fetchActiveLiveBlogs(), + refetchInterval: 10000, // Refetch every 10 seconds for ticker + }); +} + // Live Blog Mutations (Admin) export function useCreateLiveBlogUpdate() { const queryClient = useQueryClient(); @@ -109,7 +125,7 @@ export function useUpdateLiveBlog() { return useMutation({ mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) => api.updateLiveBlog(id, dto), - onSuccess: (data, variables) => { + onSuccess: () => { // Force immediate refetch of all live blog queries queryClient.invalidateQueries({ queryKey: ['liveBlog'], diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index da591d1..64647f5 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -6,6 +6,8 @@ import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' +import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' +import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' import './styles.css' const rootRoute = createRootRoute({ @@ -58,84 +60,142 @@ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => ( -
+
+ {/* Article Ticker at the top */} -
-
-
- - - - - - - -
-

- Placebo.mk -

-

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

-
+ + {/* Live Blog Ticker below article ticker */} + + +
+
+
+ {/* 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 + + + + + +
+
-

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. -

- -
-
- - - - - - -
-

Community

-

- Join thousands of readers who appreciate the finer art of Macedonian sarcasm. -

-
-
- -
- - Browse Articles - - - - -