238 lines
8.1 KiB
TypeScript
238 lines
8.1 KiB
TypeScript
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 | undefined>(undefined);
|
|
|
|
// 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>
|
|
);
|
|
} |