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)
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()

View File

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

View File

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

View File

@ -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);
// 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
);
// Save the updated entity
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
// Emit status change event if status changed
if (dto.status) {
const currentBlog = await this.findOneWithoutIncrement(id);
if (currentBlog && dto.status !== currentBlog.status) {
if (dto.status !== undefined && dto.status !== oldStatus) {
this.eventEmitter.emit('live-blog.status-change', {
blogId: id,
status: dto.status,
});
}
}
// Return the updated entity
return this.findOneWithoutIncrement(id);
} finally {
await queryRunner.release();
}
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,
});
}
}

View File

@ -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>
@ -431,6 +467,15 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
</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>
</>

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

View File

@ -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,

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)
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'],

View File

@ -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,10 +60,20 @@ 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">
{/* 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" />
@ -79,7 +91,8 @@ const indexRoute = createRoute({
</p>
</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="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">
@ -118,24 +131,71 @@ const indexRoute = createRoute({
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</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">
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>
</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
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
<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">
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>
{/* 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>