live blog implemented

This commit is contained in:
dimitar committed 2026-01-29 04:01:55 +01:00
1 parent e8893c1aae
commit 0351726eeb
36 files changed
+7759 -253

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticleTicker() {
const { data } = useQuery({
queryKey: ['ticker-articles'],
queryFn: () => api.fetchArticles({ status: 'published', limit: 10 }),
})
const articles = data?.data.slice(0, 10) || []
if (articles.length === 0) return null
return (
<div className="overflow-hidden bg-muted/50 border-y">
<div className="container mx-auto max-w-6xl px-4">
<div className="py-2 flex items-center gap-4">
<span className="text-sm font-semibold text-primary whitespace-nowrap">
Latest:
</span>
<div className="overflow-hidden flex-1 relative">
<div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => (
<Link
key={`${article.id}-${index}`}
to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
>
{article.title || 'No title'}
</Link>
))}
{/* Duplicate for seamless scrolling */}
{articles.map((article, index) => (
<Link
key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
>
{article.title || 'No title'}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { useLiveBlog, useDeleteLiveBlogUpdate } from '@/queries/live-blogs';
import { UpdatePublisher } from './UpdatePublisher';
import { LiveBlogUpdate } from '@/components/features/live-blog/LiveBlogUpdate';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import {
ArrowLeft,
Edit,
Play,
Pause,
Square,
Archive,
Eye,
MessageSquare
} from 'lucide-react';
interface LiveBlogManagerProps {
slug: string;
onBack?: () => void;
className?: string;
}
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
const [activeTab, setActiveTab] = useState('updates');
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
const handleDeleteUpdate = async (update: ApiLiveBlogUpdate) => {
if (!liveBlog) return;
if (confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
try {
await deleteUpdateMutation.mutateAsync({
liveBlogId: liveBlog.id,
updateId: update.id,
});
} catch (error) {
console.error('Failed to delete update:', error);
}
}
};
const handleEditUpdate = (update: ApiLiveBlogUpdate) => {
// The UpdatePublisher component handles editing internally
console.log('Editing update:', update);
};
if (isLoading) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="h-32 bg-muted rounded"></div>
</div>
</CardContent>
</Card>
);
}
if (error || !liveBlog) {
return (
<Card className={className}>
<CardContent className="p-6">
<div className="text-center text-destructive">
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
<p className="text-sm mb-4">
{error instanceof Error ? error.message : 'Unknown error occurred'}
</p>
{onBack && (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
)}
</div>
</CardContent>
</Card>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'live': return 'bg-green-100 text-green-800 border-green-200';
case 'draft': return 'bg-gray-100 text-gray-800 border-gray-200';
case 'ended': return 'bg-red-100 text-red-800 border-red-200';
case 'archived': return 'bg-blue-100 text-blue-800 border-blue-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 'draft': return <Edit className="w-3 h-3" />;
case 'ended': return <Square className="w-3 h-3" />;
case 'archived': return <Archive className="w-3 h-3" />;
default: return null;
}
};
return (
<div className={className}>
{/* Header */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<CardTitle className="text-2xl">{liveBlog.title}</CardTitle>
</div>
{liveBlog.description && (
<p className="text-muted-foreground mb-4">{liveBlog.description}</p>
)}
<div className="flex items-center gap-4 text-sm">
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
<div className="flex items-center gap-1">
{getStatusIcon(liveBlog.status)}
{liveBlog.status}
</div>
</Badge>
<div className="flex items-center gap-1 text-muted-foreground">
<Eye className="w-4 h-4" />
{liveBlog.viewCount} views
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<MessageSquare className="w-4 h-4" />
{liveBlog.updates?.length || 0} updates
</div>
{liveBlog.author && (
<div className="flex items-center gap-2">
{liveBlog.author.avatar && (
<img
src={liveBlog.author.avatar}
alt={liveBlog.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-muted-foreground">{liveBlog.author.name}</span>
</div>
)}
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Main content */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="updates">Updates</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="updates" className="space-y-6">
<UpdatePublisher
liveBlogId={liveBlog.id}
authorId={liveBlog.authorId || undefined}
/>
{/* Recent updates preview */}
{liveBlog.updates && liveBlog.updates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">All Updates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{liveBlog.updates.map((update) => (
<LiveBlogUpdate
key={update.id}
update={update}
showAdminControls={true}
onEdit={handleEditUpdate}
onDelete={handleDeleteUpdate}
onPinToggle={(update) => {
// Handle pin toggle
console.log('Toggle pin for update:', update);
}}
/>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Live Blog Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Status Management</h4>
<p className="text-sm text-muted-foreground mb-4">
Control the live blog status and visibility
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Play className="w-4 h-4 mr-2" />
Go Live
</Button>
<Button variant="outline" size="sm">
<Pause className="w-4 h-4 mr-2" />
Pause
</Button>
<Button variant="outline" size="sm">
<Square className="w-4 h-4 mr-2" />
End
</Button>
<Button variant="outline" size="sm">
<Archive className="w-4 h-4 mr-2" />
Archive
</Button>
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Analytics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Total Views:</span>
<div className="font-medium">{liveBlog.viewCount}</div>
</div>
<div>
<span className="text-muted-foreground">Total Updates:</span>
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
</div>
<div>
<span className="text-muted-foreground">Created:</span>
<div className="font-medium">
{new Date(liveBlog.createdAt).toLocaleDateString()}
</div>
</div>
<div>
<span className="text-muted-foreground">Last Updated:</span>
<div className="font-medium">
{new Date(liveBlog.updatedAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,263 @@
import React, { useState, useRef } from 'react';
import { useCreateLiveBlogUpdate, useUpdateLiveBlogUpdate, useLiveBlogUpdates } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import type { LiveBlogUpdate, CreateLiveBlogUpdateDto } from '@/lib/api';
import { cn } from '@/lib/utils';
import { Send, Pin, Calendar, Edit } from 'lucide-react';
interface UpdatePublisherProps {
liveBlogId: string;
authorId?: string;
className?: string;
}
export function UpdatePublisher({ liveBlogId, authorId, className }: UpdatePublisherProps) {
const [content, setContent] = useState('');
const [isPinned, setIsPinned] = useState(false);
const [scheduledAt, setScheduledAt] = useState('');
const [isScheduleMode, setIsScheduleMode] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const createMutation = useCreateLiveBlogUpdate();
const updateMutation = useUpdateLiveBlogUpdate();
const [editingUpdate, setEditingUpdate] = useState<LiveBlogUpdate | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
const updateData: CreateLiveBlogUpdateDto = {
content: content.trim(),
isPinned,
authorId,
...(isScheduleMode && scheduledAt ? { scheduledAt } : {}),
};
try {
if (editingUpdate) {
// Update existing update
await updateMutation.mutateAsync({
liveBlogId,
updateId: editingUpdate.id,
dto: {
content: content.trim(),
isPinned,
},
});
setEditingUpdate(null);
} else {
// Create new update
await createMutation.mutateAsync({
liveBlogId,
dto: updateData,
});
}
// Reset form
setContent('');
setIsPinned(false);
setScheduledAt('');
setIsScheduleMode(false);
textareaRef.current?.focus();
} catch (error) {
console.error('Failed to save update:', error);
}
};
const handleEdit = (update: LiveBlogUpdate) => {
setContent(update.content);
setIsPinned(update.isPinned);
setEditingUpdate(update);
setIsScheduleMode(false);
textareaRef.current?.focus();
};
const handleCancelEdit = () => {
setEditingUpdate(null);
setContent('');
setIsPinned(false);
setIsScheduleMode(false);
};
const isLoading = createMutation.isPending || updateMutation.isPending;
// Get minimum date for scheduling (now + 5 minutes)
const minScheduleDate = new Date().toISOString().slice(0, 16);
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{editingUpdate ? 'Edit Update' : 'Publish Update'}
{editingUpdate && (
<span className="text-xs bg-muted px-2 py-1 rounded-full">
ID: {editingUpdate.id.slice(0, 8)}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Content textarea */}
<div className="space-y-2">
<Label htmlFor="content">
{editingUpdate ? 'Edit content' : 'Update content'}
</Label>
<Textarea
id="content"
ref={textareaRef}
placeholder="What's the latest update?"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="resize-none"
disabled={isLoading}
/>
</div>
{/* Options */}
<div className="flex flex-wrap gap-4">
{/* Pin toggle */}
<div className="flex items-center space-x-2">
<Switch
id="pin"
checked={isPinned}
onCheckedChange={setIsPinned}
disabled={isLoading}
/>
<Label htmlFor="pin" className="flex items-center gap-2">
<Pin className="w-4 h-4" />
Pin to top
</Label>
</div>
{/* Schedule toggle */}
<div className="flex items-center space-x-2">
<Switch
id="schedule"
checked={isScheduleMode}
onCheckedChange={setIsScheduleMode}
disabled={isLoading}
/>
<Label htmlFor="schedule" className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
Schedule
</Label>
</div>
</div>
{/* Schedule date/time picker */}
{isScheduleMode && (
<div className="space-y-2">
<Label htmlFor="scheduleAt">Schedule for (minimum 5 minutes from now)</Label>
<input
id="scheduleAt"
type="datetime-local"
min={minScheduleDate}
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full p-2 border rounded-md"
disabled={isLoading}
/>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
<Button
type="submit"
disabled={!content.trim() || isLoading}
className="flex-1"
>
{isLoading ? (
'Saving...'
) : editingUpdate ? (
'Update'
) : (
<>
<Send className="w-4 h-4 mr-2" />
{isScheduleMode ? 'Schedule' : 'Publish'}
</>
)}
</Button>
{editingUpdate && (
<Button
type="button"
variant="outline"
onClick={handleCancelEdit}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
{/* Recent updates for editing */}
{editingUpdate === null && (
<div className="mt-6 pt-6 border-t">
<h4 className="text-sm font-medium mb-3">Recent Updates (Click to edit)</h4>
<RecentUpdates
liveBlogId={liveBlogId}
onEdit={handleEdit}
isLoading={isLoading}
/>
</div>
)}
</CardContent>
</Card>
);
}
interface RecentUpdatesProps {
liveBlogId: string;
onEdit: (update: LiveBlogUpdate) => void;
isLoading: boolean;
}
function RecentUpdates({ liveBlogId, onEdit, isLoading }: RecentUpdatesProps) {
const { data: updatesData } = useLiveBlogUpdates(liveBlogId, 1, 5);
const updates = updatesData?.data || [];
if (updates.length === 0) {
return (
<p className="text-sm text-muted-foreground">No updates yet. Start by publishing your first update!</p>
);
}
return (
<div className="space-y-2">
{updates.map((update) => (
<div
key={update.id}
className={cn(
'p-3 rounded border cursor-pointer hover:bg-muted/50 transition-colors',
isLoading && 'opacity-50 pointer-events-none'
)}
onClick={() => onEdit(update)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{update.isPinned && <Pin className="w-3 h-3 text-primary" />}
<span className="text-xs text-muted-foreground">
{new Date(update.createdAt).toLocaleString()}
</span>
</div>
<Edit className="w-3 h-3 text-muted-foreground" />
</div>
<p className="text-sm line-clamp-2">{update.content}</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,145 @@
import React from 'react';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Pin,
PinOff,
Edit,
Trash2,
Clock,
User
} from 'lucide-react';
interface LiveBlogUpdateProps {
update: ApiLiveBlogUpdate;
isEditing?: boolean;
onEdit?: (update: ApiLiveBlogUpdate) => void;
onDelete?: (update: ApiLiveBlogUpdate) => void;
onPinToggle?: (update: ApiLiveBlogUpdate) => void;
showAdminControls?: boolean;
}
export function LiveBlogUpdate({
update,
isEditing = false,
onEdit,
onDelete,
onPinToggle,
showAdminControls = false,
}: LiveBlogUpdateProps) {
const isPinned = update.isPinned;
return (
<div
className={cn(
'relative p-4 rounded-lg border transition-all duration-200',
isPinned && 'border-primary bg-primary/5 shadow-sm',
!isPinned && 'border-border hover:border-muted-foreground/20'
)}
>
{/* Pinned indicator */}
{isPinned && (
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full flex items-center gap-1">
<Pin className="w-3 h-3" />
Pinned
</div>
)}
{/* Admin controls */}
{showAdminControls && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => onPinToggle?.(update)}
className="h-8 w-8 p-0"
>
{isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit?.(update)}
className="h-8 w-8 p-0"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete?.(update)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{update.author ? (
<>
{update.author.avatar ? (
<img
src={update.author.avatar}
alt={update.author.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div>
<div className="font-medium text-sm">{update.author.name}</div>
{update.author.slug && (
<div className="text-xs text-muted-foreground">@{update.author.slug}</div>
)}
</div>
</>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<User className="w-4 h-4" />
<span className="text-sm">Anonymous</span>
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{new Date(update.createdAt).toLocaleTimeString()}</span>
</div>
</div>
{/* Update content */}
<div className="prose prose-sm max-w-none">
{isEditing ? (
<textarea
defaultValue={update.content}
className="w-full p-2 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
rows={3}
autoFocus
/>
) : (
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{update.content}
</div>
)}
</div>
{/* Scheduled indicator */}
{update.scheduledAt && new Date(update.scheduledAt) > new Date() && (
<div className="mt-2 text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
Scheduled for: {new Date(update.scheduledAt).toLocaleString()}
</div>
)}
{/* Update metadata */}
<div className="mt-2 text-xs text-muted-foreground">
ID: {update.id.slice(0, 8)}
</div>
</div>
);
}
@@ -0,0 +1,301 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useLiveBlogStream } from '@/hooks/useLiveBlogStream';
import { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface LiveBlogViewerProps {
slug: string;
className?: string;
}
export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
const [autoScroll, setAutoScroll] = useState(true);
const [newUpdatesCount, setNewUpdatesCount] = useState(0);
const [isScrolledUp, setIsScrolledUp] = useState(false);
const updatesContainerRef = useRef<HTMLDivElement>(null);
const lastUpdateCountRef = useRef(0);
const { data: liveBlog, isLoading: blogLoading, error: blogError } = useLiveBlog(slug);
const {
data: updatesData,
isLoading: updatesLoading,
refetch: refetchUpdates
} = useLiveBlogUpdates(liveBlog?.id || '', 1, 100);
const {
isConnected,
lastEvent,
connectionError,
reconnectAttempts,
connect
} = useLiveBlogStream(liveBlog?.id || '');
const scrollToBottom = useCallback(() => {
if (updatesContainerRef.current && autoScroll) {
const container = updatesContainerRef.current;
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
}, [autoScroll]);
const handleScroll = useCallback(() => {
if (!updatesContainerRef.current) return;
const container = updatesContainerRef.current;
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50;
setIsScrolledUp(!isAtBottom);
if (isAtBottom) {
setNewUpdatesCount(0);
}
}, []);
const handleAutoScrollToggle = useCallback(() => {
setAutoScroll(!autoScroll);
if (!autoScroll) {
scrollToBottom();
setNewUpdatesCount(0);
}
}, [autoScroll, scrollToBottom]);
const showNewUpdates = useCallback(() => {
setNewUpdatesCount(0);
scrollToBottom();
setIsScrolledUp(false);
}, [scrollToBottom]);
// Handle new updates from SSE
useEffect(() => {
if (lastEvent?.type === 'update') {
const handleUpdate = () => {
refetchUpdates();
if (autoScroll && !isScrolledUp) {
// Add a small delay to ensure the update is in the DOM
setTimeout(() => {
scrollToBottom();
}, 100);
} else if (!autoScroll || isScrolledUp) {
setNewUpdatesCount(prev => prev + 1);
}
};
handleUpdate();
}
}, [lastEvent, autoScroll, isScrolledUp, refetchUpdates, scrollToBottom]);
// Initial scroll to bottom when data loads
useEffect(() => {
if (updatesData?.data && updatesData.data.length > 0) {
const currentUpdateCount = updatesData.data.length;
// Only auto-scroll if this is the initial load or user is at bottom
if (currentUpdateCount > lastUpdateCountRef.current && !isScrolledUp) {
setTimeout(scrollToBottom, 100);
}
lastUpdateCountRef.current = currentUpdateCount;
}
}, [updatesData, scrollToBottom, isScrolledUp]);
if (blogLoading) {
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="space-y-2">
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
<div className="h-16 bg-muted rounded"></div>
</div>
</div>
</CardContent>
</Card>
);
}
if (blogError || !liveBlog) {
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardContent className="p-6">
<div className="text-center text-destructive">
<h3 className="text-lg font-semibold mb-2">Failed to load live blog</h3>
<p className="text-sm">
{blogError instanceof Error ? blogError.message : 'Unknown error occurred'}
</p>
</div>
</CardContent>
</Card>
);
}
const updates = updatesData?.data || [];
const isLive = liveBlog.status === 'live';
return (
<Card className={cn('w-full max-w-4xl mx-auto', className)}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="text-2xl mb-2">{liveBlog.title}</CardTitle>
{liveBlog.description && (
<p className="text-muted-foreground">{liveBlog.description}</p>
)}
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
isLive ? 'bg-green-500' : 'bg-gray-400'
)} />
<span>{isLive ? 'Live' : 'Ended'}</span>
</div>
{isLive && (
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
isConnected ? 'bg-green-500' : 'bg-red-500'
)} />
<span>
{isConnected ? 'Connected' : `Reconnecting... (${reconnectAttempts})`}
</span>
</div>
)}
<span>{liveBlog.viewCount} views</span>
<span>{updates.length} updates</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={autoScroll ? 'default' : 'outline'}
size="sm"
onClick={handleAutoScrollToggle}
>
{autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF'}
</Button>
{!isConnected && (
<Button
variant="outline"
size="sm"
onClick={connect}
>
Reconnect
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{connectionError && (
<div className="m-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center justify-between">
<p className="text-sm text-destructive">
{connectionError}
</p>
<Button
variant="outline"
size="sm"
onClick={connect}
>
Try Again
</Button>
</div>
</div>
)}
<div
ref={updatesContainerRef}
className="max-h-[600px] overflow-y-auto px-6 pb-6"
onScroll={handleScroll}
>
{updates.length === 0 && !updatesLoading ? (
<div className="text-center py-12 text-muted-foreground">
<p>No updates yet. Check back soon!</p>
</div>
) : (
<div className="space-y-4">
{updates.map((update) => (
<LiveBlogUpdate key={update.id} update={update} />
))}
{updatesLoading && (
<div className="animate-pulse">
<div className="h-16 bg-muted rounded"></div>
</div>
)}
</div>
)}
</div>
{/* New updates indicator */}
{isScrolledUp && newUpdatesCount > 0 && (
<div className="sticky bottom-0 bg-background border-t p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{newUpdatesCount} new {newUpdatesCount === 1 ? 'update' : 'updates'}
</span>
<Button size="sm" onClick={showNewUpdates}>
Show Updates
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface LiveBlogUpdateProps {
update: ApiLiveBlogUpdate;
}
function LiveBlogUpdate({ update }: LiveBlogUpdateProps) {
const isPinned = update.isPinned;
return (
<div
className={cn(
'relative p-4 rounded-lg border',
isPinned && 'border-primary bg-primary/5'
)}
>
{isPinned && (
<div className="absolute -top-2 -left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full">
Pinned
</div>
)}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{update.author && (
<>
{update.author.avatar && (
<img
src={update.author.avatar}
alt={update.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-sm font-medium">{update.author.name}</span>
</>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(update.createdAt).toLocaleTimeString()}
</span>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{update.content}
</div>
</div>
);
}
@@ -0,0 +1,97 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading article</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Article not found</div>
</div>
)
}
return (
<article className="max-w-3xl mx-auto">
<Link
to="/articles"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
>
<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="m15 18-6-6 6-6" />
<path d="M19 6H5" />
</svg>
Back to articles
</Link>
<h1 className="text-4xl font-bold mb-6">{data.title}</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-8">
<span>
{new Date(data.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{data.views} views</span>
{data.author && (
<>
<span>•</span>
<span>By {data.author.name}</span>
</>
)}
</div>
{data.featuredImage && (
<img
src={data.featuredImage}
alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
/>
)}
<div className="prose prose-slate max-w-none">
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2">
{data.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
</div>
)}
</article>
)
}
@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticlesComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={article.id}
to={`/articles/${article.id}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,12 @@
import { LiveBlogManager } from '@/components/admin/live-blog/LiveBlogManager'
export function LiveBlogAdminComponent({ slug }: { slug: string }) {
return (
<div className="py-8">
<LiveBlogManager
slug={slug}
onBack={() => window.history.back()}
/>
</div>
)
}
@@ -0,0 +1,5 @@
import { LiveBlogViewer } from '@/components/features/live-blog/LiveBlogViewer'
export function LiveBlogDetailComponent({ slug }: { slug: string }) {
return <LiveBlogViewer slug={slug} className="py-8" />
}
@@ -0,0 +1,101 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function LiveBlogsComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['liveBlogs'],
queryFn: () => api.fetchLiveBlogs({ status: 'live' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading live blogs...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading live blogs</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Live Blogs</h1>
<p className="text-muted-foreground">Breaking news and live updates</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data?.data.map((liveBlog) => (
<Link
key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm text-muted-foreground">
{liveBlog.status === 'live' ? 'LIVE NOW' : liveBlog.status.toUpperCase()}
</span>
</div>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{liveBlog.title}
</h2>
{liveBlog.description && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{liveBlog.description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>{liveBlog.viewCount} views</span>
<span>{liveBlog.updates?.length || 0} updates</span>
</div>
<span>
{new Date(liveBlog.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{liveBlog.author && (
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
{liveBlog.author.avatar && (
<img
src={liveBlog.author.avatar}
alt={liveBlog.author.name}
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-sm text-muted-foreground">
By {liveBlog.author.name}
</span>
</div>
)}
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No live blogs are currently active. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { badgeVariants } from "./badge-variants"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge }
@@ -0,0 +1,30 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
+3 -31
View File
@@ -1,37 +1,9 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -53,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }