pin to homepage and liveblog ticker
This commit is contained in:
parent
5a457fa99f
commit
4ccb65ba88
@ -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()
|
||||
|
||||
@ -174,6 +174,9 @@ export class LiveBlog {
|
||||
})
|
||||
status: LiveBlogStatus;
|
||||
|
||||
@Column({ default: false })
|
||||
isPinned: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
strapiId: string;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<LiveBlog> {
|
||||
// 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);
|
||||
// Save the updated entity
|
||||
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// 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();
|
||||
// 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<void> {
|
||||
@ -444,4 +445,25 @@ export class LiveBlogService implements OnModuleInit {
|
||||
.orderBy('updates.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPinned(): Promise<LiveBlog[]> {
|
||||
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<LiveBlog[]> {
|
||||
return this.liveBlogRepository.find({
|
||||
where: { status: LiveBlogStatus.LIVE },
|
||||
relations: ['author', 'category'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UpdateLiveBlogDto>({});
|
||||
|
||||
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'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={liveBlog.isPinned ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleTogglePin}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
{liveBlog.isPinned ? (
|
||||
<>
|
||||
<PinOff className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unpin'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Pin to Homepage'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -422,15 +458,24 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
|
||||
<span className="font-medium text-right max-w-xs">{liveBlog.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(liveBlog.status)}
|
||||
{liveBlog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(liveBlog.status)}
|
||||
{liveBlog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pinned:</span>
|
||||
<Badge variant={liveBlog.isPinned ? "default" : "outline"}>
|
||||
<div className="flex items-center gap-1">
|
||||
{liveBlog.isPinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
|
||||
{liveBlog.isPinned ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
238
frontend/src/components/features/live-blog/LiveBlogTicker.tsx
Normal file
238
frontend/src/components/features/live-blog/LiveBlogTicker.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animationRef = useRef<number>();
|
||||
|
||||
// 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 (
|
||||
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="animate-pulse h-8 w-20 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Ticker header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-primary" />
|
||||
<span className="font-medium text-sm">Live Now</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeBlogs.length} active
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePauseToggle}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-3 h-3" />
|
||||
) : (
|
||||
<Pause className="w-3 h-3" />
|
||||
)}
|
||||
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<span className="text-xs">View All</span>
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker content */}
|
||||
<div
|
||||
ref={tickerRef}
|
||||
className="relative h-12 overflow-hidden"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="absolute top-0 left-0 h-full flex items-center whitespace-nowrap"
|
||||
style={{
|
||||
transform: `translateX(-${scrollPosition}px)`,
|
||||
transition: isPaused ? 'transform 0.3s ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{displayBlogs.map((blog, index) => (
|
||||
<React.Fragment key={blog.id}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
<span className="font-medium text-sm group-hover:text-primary transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Separator (except after last item) */}
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Duplicate content for seamless looping */}
|
||||
{displayBlogs.map((blog, index) => (
|
||||
<React.Fragment key={`${blog.id}-dup`}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
<span className="font-medium text-sm group-hover:text-primary transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{autoScroll && totalWidth > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Pinned Live Blogs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-2 bg-muted rounded w-1/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Play className="w-3 h-3" />;
|
||||
case 'ended': return <Square className="w-3 h-3" />;
|
||||
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 (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Live Coverage
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pinnedBlogs.length} pinned
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
{displayBlogs.map((blog) => {
|
||||
const latestUpdate = getLatestUpdate(blog);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={blog.id}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="block group"
|
||||
>
|
||||
<div className="p-3 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors line-clamp-2">
|
||||
{blog.title}
|
||||
</h3>
|
||||
|
||||
{blog.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{blog.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
className={getStatusColor(blog.status)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(blog.status)}
|
||||
{blog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Latest update preview */}
|
||||
{latestUpdate && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTime(latestUpdate.createdAt)}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2">{latestUpdate.content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>{blog.viewCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>{blog.updates?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-primary">
|
||||
<span className="text-xs">View live</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pinnedBlogs.length > maxItems && (
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link
|
||||
to="/live-blogs"
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
<span>View all pinned blogs</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
<LiveBlogTicker />
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Live Blogs</h1>
|
||||
<p className="text-muted-foreground">Breaking news and live updates</p>
|
||||
|
||||
@ -258,6 +258,22 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
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<LiveBlog[]> {
|
||||
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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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: () => (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
{/* Article Ticker at the top */}
|
||||
<ArticleTicker />
|
||||
<div className="py-12 md:py-20">
|
||||
<div className="max-w-4xl mx-auto text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
Placebo<span className="text-primary">.mk</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
{/* Live Blog Ticker below article ticker */}
|
||||
<LiveBlogTicker className="mt-4" />
|
||||
|
||||
<div className="py-8 md:py-12">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Main content - 3 columns */}
|
||||
<div className="lg:col-span-3 space-y-8">
|
||||
{/* Hero section */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
Placebo<span className="text-primary">.mk</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features grid */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call to action */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
||||
Dive into our articles or follow live coverage of breaking news as it happens.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
to="/articles"
|
||||
className="inline-flex items-center justify-center 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 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
|
||||
>
|
||||
Read Articles
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
to="/live-blogs"
|
||||
className="inline-flex items-center justify-center 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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
|
||||
>
|
||||
View Live Blogs
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
{/* Sidebar - 1 column */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogSidebar />
|
||||
|
||||
{/* Additional sidebar content */}
|
||||
<div className="mt-6 rounded-xl border bg-card p-6">
|
||||
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Live coverage updated in real-time</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span>No ads, no sponsors, no BS</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
|
||||
<span>100% independent journalism</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Community</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Join thousands of readers who appreciate the finer art of Macedonian sarcasm.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<Link
|
||||
to="/articles"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Articles
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user