pin to homepage and liveblog ticker

This commit is contained in:
echo 2026-01-29 06:06:54 +01:00
parent 5a457fa99f
commit 4ccb65ba88
11 changed files with 782 additions and 157 deletions

View File

@ -138,6 +138,10 @@ export class CreateLiveBlogDto {
@IsEnum(LiveBlogStatus) @IsEnum(LiveBlogStatus)
status?: LiveBlogStatus; status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@ -168,6 +172,10 @@ export class UpdateLiveBlogDto {
@IsEnum(LiveBlogStatus) @IsEnum(LiveBlogStatus)
status?: LiveBlogStatus; status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@ -234,8 +242,12 @@ export class FindLiveBlogsDto {
author?: string; author?: string;
@IsOptional() @IsOptional()
@IsEnum(LiveBlogStatus) @IsString()
status?: LiveBlogStatus; status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@ -174,6 +174,9 @@ export class LiveBlog {
}) })
status: LiveBlogStatus; status: LiveBlogStatus;
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true }) @Column({ nullable: true })
strapiId: string; strapiId: string;

View File

@ -29,6 +29,22 @@ export class LiveBlogController {
constructor(private readonly liveBlogService: LiveBlogService) {} constructor(private readonly liveBlogService: LiveBlogService) {}
// Live Blog CRUD operations // 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() @Post()
create( create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto, @Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
@ -43,11 +59,6 @@ export class LiveBlogController {
return this.liveBlogService.findAll(dto); return this.liveBlogService.findAll(dto);
} }
@Get('recent')
getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
}
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id); return this.liveBlogService.findOne(id);

View File

@ -5,7 +5,7 @@ import {
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, In } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express'; import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities'; import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
@ -96,7 +96,8 @@ export class LiveBlogService implements OnModuleInit {
const { const {
category, category,
author, author,
status = LiveBlogStatus.LIVE, status,
isPinned,
search, search,
page = 1, page = 1,
limit = 10, limit = 10,
@ -105,8 +106,23 @@ export class LiveBlogService implements OnModuleInit {
const queryBuilder = this.liveBlogRepository const queryBuilder = this.liveBlogRepository
.createQueryBuilder('liveBlog') .createQueryBuilder('liveBlog')
.leftJoinAndSelect('liveBlog.author', 'author') .leftJoinAndSelect('liveBlog.author', 'author')
.leftJoinAndSelect('liveBlog.category', 'category') .leftJoinAndSelect('liveBlog.category', 'category');
.where('liveBlog.status = :status', { status });
// 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) { if (category) {
queryBuilder.andWhere('category.slug = :category', { 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 const [data, total] = await queryBuilder
.orderBy('liveBlog.createdAt', 'DESC') .orderBy('liveBlog.createdAt', 'DESC')
.skip((page - 1) * limit) .skip((page - 1) * limit)
@ -178,82 +198,63 @@ export class LiveBlogService implements OnModuleInit {
} }
async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> { async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
// Build SET clause for update const liveBlog = await this.liveBlogRepository.findOne({
const setClauses: string[] = []; where: { id },
const params: any[] = []; 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) { if (dto.title !== undefined) {
setClauses.push('title = ?'); liveBlog.title = dto.title;
params.push(dto.title);
} }
if (dto.slug !== undefined) { if (dto.slug !== undefined) {
setClauses.push('slug = ?'); liveBlog.slug = dto.slug;
params.push(dto.slug);
} }
if (dto.description !== undefined) { if (dto.description !== undefined) {
setClauses.push('description = ?'); liveBlog.description = dto.description;
params.push(dto.description);
} }
if (dto.status !== undefined) { if (dto.status !== undefined) {
setClauses.push('status = ?'); liveBlog.status = dto.status;
params.push(dto.status);
} }
if (dto.strapiId !== undefined) { if (dto.strapiId !== undefined) {
setClauses.push('strapiId = ?'); liveBlog.strapiId = dto.strapiId;
params.push(dto.strapiId);
} }
if (dto.authorId !== undefined) { if (dto.authorId !== undefined) {
setClauses.push('authorId = ?'); liveBlog.authorId = dto.authorId;
params.push(dto.authorId);
} }
if (dto.categoryId !== undefined) { if (dto.categoryId !== undefined) {
setClauses.push('categoryId = ?'); liveBlog.categoryId = dto.categoryId;
params.push(dto.categoryId);
} }
// Always update updatedAt if (dto.isPinned !== undefined) {
setClauses.push('updatedAt = CURRENT_TIMESTAMP'); liveBlog.isPinned = dto.isPinned;
if (setClauses.length === 0) {
// Nothing to update
return this.findOneWithoutIncrement(id);
} }
// Add id to params // Save the updated entity
params.push(id); 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 // Emit status change event if status changed
if (dto.status) { if (dto.status !== undefined && dto.status !== oldStatus) {
const currentBlog = await this.findOneWithoutIncrement(id);
if (currentBlog && dto.status !== currentBlog.status) {
this.eventEmitter.emit('live-blog.status-change', { this.eventEmitter.emit('live-blog.status-change', {
blogId: id, blogId: id,
status: dto.status, status: dto.status,
}); });
} }
}
// Return the updated entity return updatedBlog;
return this.findOneWithoutIncrement(id);
} finally {
await queryRunner.release();
}
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
@ -444,4 +445,25 @@ export class LiveBlogService implements OnModuleInit {
.orderBy('updates.createdAt', 'DESC') .orderBy('updates.createdAt', 'DESC')
.getMany(); .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,
});
}
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { import {
useLiveBlog, useLiveBlog,
useDeleteLiveBlogUpdate, useDeleteLiveBlogUpdate,
@ -23,7 +23,9 @@ import {
Square, Square,
Archive, Archive,
Eye, Eye,
MessageSquare MessageSquare,
Pin,
PinOff
} from 'lucide-react'; } from 'lucide-react';
interface LiveBlogManagerProps { interface LiveBlogManagerProps {
@ -37,7 +39,7 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({}); const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
const { data: liveBlog, isLoading, error, refetch } = useLiveBlog(slug); const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
const deleteUpdateMutation = useDeleteLiveBlogUpdate(); const deleteUpdateMutation = useDeleteLiveBlogUpdate();
const updateLiveBlogMutation = useUpdateLiveBlog(); const updateLiveBlogMutation = useUpdateLiveBlog();
const deleteLiveBlogMutation = useDeleteLiveBlog(); 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 () => { const handleDeleteLiveBlog = async () => {
if (!liveBlog) return; if (!liveBlog) return;
@ -402,6 +419,25 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'} {updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'}
</Button> </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>
</div> </div>
@ -431,6 +467,15 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
</div> </div>
</Badge> </Badge>
</div> </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>
</div> </div>
</> </>

View 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>
);
}

View File

@ -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>
);
}

View File

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import { LiveBlogTicker } from '@/components/features/live-blog/LiveBlogTicker'
export function LiveBlogsComponent() { export function LiveBlogsComponent() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@ -25,7 +26,9 @@ export function LiveBlogsComponent() {
} }
return ( return (
<div> <div className="space-y-6">
<LiveBlogTicker />
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold">Live Blogs</h1> <h1 className="text-3xl font-bold">Live Blogs</h1>
<p className="text-muted-foreground">Breaking news and live updates</p> <p className="text-muted-foreground">Breaking news and live updates</p>

View File

@ -258,6 +258,22 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
return response.json(); 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 // Admin functions
export async function createLiveBlogUpdate( export async function createLiveBlogUpdate(
liveBlogId: string, liveBlogId: string,

View File

@ -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) // Live Blog Mutations (Admin)
export function useCreateLiveBlogUpdate() { export function useCreateLiveBlogUpdate() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -109,7 +125,7 @@ export function useUpdateLiveBlog() {
return useMutation({ return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) => mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) =>
api.updateLiveBlog(id, dto), api.updateLiveBlog(id, dto),
onSuccess: (data, variables) => { onSuccess: () => {
// Force immediate refetch of all live blog queries // Force immediate refetch of all live blog queries
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['liveBlog'], queryKey: ['liveBlog'],

View File

@ -6,6 +6,8 @@ import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent' import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' 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' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@ -58,10 +60,20 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: () => ( component: () => (
<div> <div className="space-y-6">
{/* Article Ticker at the top */}
<ArticleTicker /> <ArticleTicker />
<div className="py-12 md:py-20">
<div className="max-w-4xl mx-auto text-center mb-12"> {/* 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"> <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"> <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" /> <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" />
@ -79,7 +91,8 @@ const indexRoute = createRoute({
</p> </p>
</div> </div>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto"> {/* 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="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"> <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"> <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">
@ -118,24 +131,71 @@ const indexRoute = createRoute({
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold mb-2">Community</h3> <h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Join thousands of readers who appreciate the finer art of Macedonian sarcasm. Real-time updates on breaking news with our live blogging system. No delays, just facts.
</p> </p>
</div> </div>
</div> </div>
<div className="mt-16 text-center"> {/* 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 <Link
to="/articles" 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" 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"
> >
Browse Articles 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"> <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="M5 12h14" />
<path d="m12 5 7 7-7 7" /> <path d="m12 5 7 7-7 7" />
</svg> </svg>
</Link> </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>
{/* 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>
</div>
</div> </div>
</div> </div>
</div> </div>