pin to homepage and liveblog ticker

This commit is contained in:
dimitar committed 2026-01-29 06:06:54 +01:00
1 parent 5a457fa99f
commit 4ccb65ba88
11 files changed
+782 -157

No files matched your search

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