pwa ready

This commit is contained in:
dimitar committed 2026-02-22 01:11:20 +01:00
1 parent 83c60ce40d
commit f1f16a80a5
95 files changed
+24537

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { Zap } from 'lucide-react'
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-foreground text-background border-b-4 border-accent">
<div className="py-2 flex items-center">
<div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
<Zap className="w-4 h-4" />
Топ вести
</div>
<div className="overflow-hidden flex-1">
<div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => (
<Link
key={`${article.id}-${index}`}
to={`/articles/${article.id}`}
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
>
{article.title || 'No title'}
</Link>
))}
{articles.map((article, index) => (
<Link
key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}`}
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
>
{article.title || 'No title'}
</Link>
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { useCreateLiveBlog } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNavigate } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
interface CreateLiveBlogProps {
onSuccess?: () => void;
className?: string;
}
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
slug: '',
description: '',
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const createMutation = useCreateLiveBlog();
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-')
.trim();
};
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const title = e.target.value;
setFormData({
...formData,
title,
slug: formData.slug || generateSlug(title),
});
if (errors.title) setErrors({ ...errors, title: '' });
};
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, slug: e.target.value });
if (errors.slug) setErrors({ ...errors, slug: '' });
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.slug.trim()) {
newErrors.slug = 'Slug is required';
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const result = await createMutation.mutateAsync(formData);
// Reset form
setFormData({
title: '',
slug: '',
description: '',
status: 'draft',
});
setErrors({});
// Navigate to the new live blog
navigate({ to: `/admin/live-blogs/${result.slug}` });
if (onSuccess) onSuccess();
} catch (error) {
console.error('Failed to create live blog:', error);
setErrors({ submit: 'Failed to create live blog. Please try again.' });
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Create New Live Blog
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={handleTitleChange}
placeholder="Enter live blog title"
disabled={createMutation.isPending}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={formData.slug}
onChange={handleSlugChange}
placeholder="URL-friendly identifier"
disabled={createMutation.isPending}
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug}</p>
)}
<p className="text-xs text-muted-foreground">
This will be used in the URL: /live-blogs/your-slug-here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description for this live blog"
rows={3}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Initial Status</Label>
<Select
value={formData.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setFormData({ ...formData, status: value })
}
disabled={createMutation.isPending}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
You can change this later in the settings
</p>
</div>
{errors.submit && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{errors.submit}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="submit"
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,531 @@
import { useState } from 'react';
import {
useLiveBlog,
useDeleteLiveBlogUpdate,
useUpdateLiveBlog,
useDeleteLiveBlog
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { LiveBlogUpdate as ApiLiveBlogUpdate, UpdateLiveBlogDto } from '@/lib/api';
import {
ArrowLeft,
Edit,
Play,
Square,
Archive,
Eye,
MessageSquare,
Pin,
PinOff
} from 'lucide-react';
interface LiveBlogManagerProps {
slug: string;
onBack?: () => void;
className?: string;
}
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
const [activeTab, setActiveTab] = useState('updates');
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
const updateLiveBlogMutation = useUpdateLiveBlog();
const deleteLiveBlogMutation = useDeleteLiveBlog();
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);
};
const handleStartEdit = () => {
if (!liveBlog) return;
setIsEditing(true);
setEditForm({
title: liveBlog.title,
slug: liveBlog.slug,
description: liveBlog.description || '',
status: liveBlog.status,
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm({});
};
const handleSaveEdit = async () => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: editForm,
});
setIsEditing(false);
setEditForm({});
} catch (error) {
console.error('Failed to update live blog:', error);
}
};
const handleStatusChange = async (newStatus: 'draft' | 'live' | 'ended' | 'archived') => {
if (!liveBlog) return;
try {
await updateLiveBlogMutation.mutateAsync({
id: liveBlog.id,
dto: { status: newStatus },
});
} catch (error) {
console.error('Failed to update status:', error);
alert(`Failed to update status: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
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;
if (confirm('Are you sure you want to delete this live blog? This action cannot be undone and will delete all updates.')) {
try {
await deleteLiveBlogMutation.mutateAsync(liveBlog.id);
if (onBack) onBack();
} catch (error) {
console.error('Failed to delete live blog:', error);
}
}
};
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>
<div className="flex items-center justify-between">
<CardTitle>Live Blog Settings</CardTitle>
{!isEditing ? (
<Button variant="outline" size="sm" onClick={handleStartEdit}>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveEdit}>
Save Changes
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{isEditing ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={editForm.title || ''}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={editForm.slug || ''}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
placeholder="URL-friendly identifier"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={editForm.description || ''}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={editForm.status}
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
setEditForm({ ...editForm, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="live">Live</SelectItem>
<SelectItem value="ended">Ended</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<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 flex-wrap gap-2">
{liveBlog.status !== 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('live')}
disabled={updateLiveBlogMutation.isPending}
>
<Play className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Go Live'}
</Button>
)}
{liveBlog.status === 'live' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('ended')}
disabled={updateLiveBlogMutation.isPending}
>
<Square className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'End Live Session'}
</Button>
)}
{liveBlog.status !== 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('archived')}
disabled={updateLiveBlogMutation.isPending}
>
<Archive className="w-4 h-4 mr-2" />
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Archive'}
</Button>
)}
{liveBlog.status === 'archived' && (
<Button
variant="outline"
size="sm"
onClick={() => handleStatusChange('draft')}
disabled={updateLiveBlogMutation.isPending}
>
<Edit className="w-4 h-4 mr-2" />
{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>
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Live Blog Information</h4>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Title:</span>
<span className="font-medium">{liveBlog.title}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Slug:</span>
<span className="font-medium">{liveBlog.slug}</span>
</div>
{liveBlog.description && (
<div className="flex justify-between">
<span className="text-muted-foreground">Description:</span>
<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">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 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 className="p-4 border border-destructive/20 rounded-lg">
<h4 className="font-medium mb-2 text-destructive">Danger Zone</h4>
<p className="text-sm text-muted-foreground mb-4">
Once you delete a live blog, there is no going back. Please be certain.
</p>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteLiveBlog}
disabled={deleteLiveBlogMutation.isPending}
>
{deleteLiveBlogMutation.isPending ? 'Deleting...' : 'Delete Live Blog'}
</Button>
</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>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface LoginFormProps {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
}
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
const { login, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
{onSwitchToRegister && (
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-primary hover:underline"
disabled={isLoading}
>
Register here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
import React from 'react';
import { Navigate } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'admin' | 'contributor' | 'user';
redirectTo?: string;
}
export function ProtectedRoute({
children,
requiredRole,
redirectTo = '/'
}: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/" />;
}
return <>{children}</>;
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface RegisterFormProps {
onSuccess?: () => void;
onSwitchToLogin?: () => void;
}
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
const { register, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username, email, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>
Create a new account to start commenting and reacting
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Choose a password (min 6 characters)"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Register'}
</Button>
{onSwitchToLogin && (
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-primary hover:underline"
disabled={isLoading}
>
Login here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { useCreateComment } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import { Card, CardContent } from '../../ui/card';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
import type { Comment } from '../../../lib/api';
interface CommentItemProps {
comment: Comment;
articleId?: string;
liveBlogId?: string;
depth?: number;
}
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
const { isAuthenticated } = useAuth();
const [showReplyForm, setShowReplyForm] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const createCommentMutation = useCreateComment();
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || !isAuthenticated) return;
setIsSubmittingReply(true);
try {
await createCommentMutation.mutateAsync({
content: replyContent,
articleId,
liveBlogId,
parentCommentId: comment.id,
});
setReplyContent('');
setShowReplyForm(false);
} catch (error) {
console.error('Failed to post reply:', error);
} finally {
setIsSubmittingReply(false);
}
};
// Maximum depth to prevent infinite nesting (optional)
const maxDepth = 5;
const canReply = depth < maxDepth;
return (
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="font-medium">
{comment.user?.username || 'Анонимен корисник'}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
</div>
</div>
{comment.user?.role === 'admin' && (
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
Администратор
</span>
)}
</div>
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
{/* Reply button and form */}
{isAuthenticated && canReply && (
<div className="mt-4">
{!showReplyForm ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplyForm(true)}
className="text-sm"
>
Одговори
</Button>
) : (
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
<form onSubmit={handleSubmitReply}>
<Textarea
placeholder="Вашиот одговор..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
className="min-h-[80px] mb-3"
disabled={isSubmittingReply}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowReplyForm(false);
setReplyContent('');
}}
disabled={isSubmittingReply}
>
Откажи
</Button>
<Button
type="submit"
size="sm"
disabled={!replyContent.trim() || isSubmittingReply}
>
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
</Button>
</div>
</form>
</div>
)}
</div>
)}
{/* Reactions (if implemented) */}
{comment.reactions && (
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Render replies recursively */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
articleId={articleId}
liveBlogId={liveBlogId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { useComments, useCreateComment } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import { Card, CardContent } from '../../ui/card';
import { CommentItem } from './CommentItem';
interface CommentSectionProps {
articleId?: string;
liveBlogId?: string;
}
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
const { isAuthenticated } = useAuth();
const [newComment, setNewComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: commentsData, isLoading } = useComments({
articleId,
liveBlogId,
limit: 50,
});
const createCommentMutation = useCreateComment();
const comments = commentsData?.data || [];
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !isAuthenticated) return;
setIsSubmitting(true);
try {
await createCommentMutation.mutateAsync({
content: newComment,
articleId,
liveBlogId,
});
setNewComment('');
} catch (error) {
console.error('Failed to post comment:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-12 pt-8 border-t">
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
{isAuthenticated ? (
<Card className="mb-8">
<CardContent className="pt-6">
<form onSubmit={handleSubmitComment}>
<Textarea
placeholder="Што мислите за овој напис?"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[100px] mb-4"
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!newComment.trim() || isSubmitting}
>
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
</Button>
</div>
</form>
</CardContent>
</Card>
) : (
<Card className="mb-8">
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground mb-4">
Најавете се за да можете да коментирате
</p>
<Button asChild>
<a href="/auth">Најави се</a>
</Button>
</CardContent>
</Card>
)}
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување коментари...</p>
</div>
) : comments.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground">
Сè уште нема коментари. Бидете првиот што ќе коментира!
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
articleId={articleId}
liveBlogId={liveBlogId}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
import { useAuth } from '../../../contexts/AuthContext';
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface ReactionButtonsProps {
articleId?: string;
liveBlogId?: string;
commentId?: string;
compact?: boolean;
}
export function ReactionButtons({
articleId,
liveBlogId,
commentId,
compact = false
}: ReactionButtonsProps) {
const { isAuthenticated } = useAuth();
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
const addReactionMutation = useAddReaction();
const userReactionType = userReaction?.type;
const handleReaction = async (type: 'like' | 'dislike') => {
if (!isAuthenticated) {
window.location.href = '/auth';
return;
}
try {
await addReactionMutation.mutateAsync({
type,
articleId,
liveBlogId,
commentId,
});
} catch (error) {
console.error('Failed to add reaction:', error);
}
};
const likes = counts?.likes || 0;
const dislikes = counts?.dislikes || 0;
if (compact) {
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('like')}
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
>
<ThumbsUp className="w-4 h-4 mr-1" />
{likes > 0 && <span>{likes}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('dislike')}
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
>
<ThumbsDown className="w-4 h-4 mr-1" />
{dislikes > 0 && <span>{dislikes}</span>}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'like' ? 'default' : 'outline'}
size="sm"
onClick={() => handleReaction('like')}
className="gap-2"
>
<ThumbsUp className="w-4 h-4" />
<span>Допаѓа ми</span>
{likes > 0 && (
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
{likes}
</span>
)}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleReaction('dislike')}
className="gap-2"
>
<ThumbsDown className="w-4 h-4" />
<span>Не ми се допаѓа</span>
{dislikes > 0 && (
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
{dislikes}
</span>
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,213 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
interface LiveBlogTickerProps {
className?: string;
maxItems?: number;
autoScroll?: boolean;
scrollSpeed?: number;
}
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);
const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => {
if (contentRef.current) {
const width = contentRef.current.scrollWidth;
setTotalWidth(width);
}
}, [activeBlogs]);
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;
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={`border-brutal-sm bg-card 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;
}
const displayBlogs = activeBlogs.slice(0, maxItems);
const handlePauseToggle = () => {
setIsPaused(!isPaused);
};
const handleBlogClick = () => {
setScrollPosition(0);
};
return (
<div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
<div className="flex items-center gap-3">
<Radio className="w-4 h-4 text-accent animate-pulse" />
<span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
{activeBlogs.length}
</span>
</div>
<div className="flex items-center gap-2">
{autoScroll && (
<Button
variant="brutal"
size="sm"
onClick={handlePauseToggle}
className="h-7 px-2 text-xs"
>
{isPaused ? (
<Play className="w-3 h-3" />
) : (
<Pause className="w-3 h-3" />
)}
</Button>
)}
<Link to="/live-blogs">
<Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
Сите
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</div>
</div>
<div
ref={tickerRef}
className="relative h-12 overflow-hidden"
>
<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) => (
<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 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
</div>
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length}
</span>
)}
</div>
</Link>
</React.Fragment>
))}
{displayBlogs.map((blog) => (
<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 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-2">
<div className="relative">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
</div>
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title}
</span>
{blog.updates && blog.updates.length > 0 && (
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length}
</span>
)}
</div>
</Link>
</React.Fragment>
))}
</div>
</div>
{autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2">
<div className="h-1 bg-foreground/10">
<div
className="h-full bg-accent transition-all duration-300"
style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,145 @@
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 { 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="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,238 @@
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 type { LiveBlog } from '@/lib/api';
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 (isLoading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Live Coverage
</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) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Pin className="w-4 h-4" />
Live Coverage
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
Error loading live coverage
</p>
</CardContent>
</Card>
);
}
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">
{displayBlogs.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">
No pinned live blogs at the moment
</p>
<p className="text-xs text-muted-foreground mt-1">
Check back later for live coverage
</p>
</div>
) : (
<>
<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 p-3 rounded-lg border hover:bg-accent/50 transition-colors group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
{blog.title}
</h4>
{blog.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{blog.description}
</p>
)}
</div>
<Badge
className={`${getStatusColor(blog.status)} text-xs`}
variant="outline"
>
<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="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>{blog.updates?.length || 0} updates</span>
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<span>{blog.viewCount} views</span>
</div>
</div>
</Link>
);
})}
</div>
{/* View all button */}
{(pinnedBlogs || []).length > maxItems && (
<Button
variant="ghost"
size="sm"
className="w-full mt-4"
asChild
>
<Link to="/live-blogs">
View all pinned blogs
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
</Button>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
import { copyToClipboard } from '@/lib/social-utils';
interface CopyLinkButtonProps {
url: string;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
onCopy?: (success: boolean) => void;
}
export function CopyLinkButton({
url,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
onCopy,
}: CopyLinkButtonProps) {
const [isCopied, setIsCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleCopy = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const success = await copyToClipboard(url);
setIsCopied(success);
if (onCopy) {
onCopy(success);
}
// Reset copied state after 2 seconds
if (success) {
setTimeout(() => setIsCopied(false), 2000);
}
} catch (error) {
console.error('Failed to copy:', error);
if (onCopy) {
onCopy(false);
}
} finally {
setIsLoading(false);
}
};
const label = isCopied ? 'Copied!' : 'Copy Link';
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleCopy}
disabled={isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : isCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { type SharePlatform, getPlatformLabel } from '@/lib/social-utils';
import {
Facebook,
Twitter,
MessageCircle,
Send,
Mail,
Link,
Share2
} from 'lucide-react';
interface ShareButtonProps {
platform: SharePlatform;
onClick: () => void;
disabled?: boolean;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
}
export function ShareButton({
platform,
onClick,
disabled = false,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
}: ShareButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
if (disabled || isLoading) return;
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
const label = getPlatformLabel(platform);
// Get the appropriate icon component
const getIconComponent = () => {
switch (platform) {
case 'facebook':
return Facebook;
case 'twitter':
return Twitter;
case 'whatsapp':
return MessageCircle;
case 'telegram':
return Send;
case 'email':
return Mail;
case 'link':
return Link;
default:
return Share2;
}
};
const IconComponent = getIconComponent();
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleClick}
disabled={disabled || isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<IconComponent className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { ShareButton } from './ShareButton';
import { CopyLinkButton } from './CopyLinkButton';
import { type SharePlatform, type ShareData, getShareUrl } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
export type SocialShareVariant = 'default' | 'compact' | 'footer' | 'floating';
interface SocialShareButtonsProps extends ShareData {
articleId: string;
variant?: SocialShareVariant;
className?: string;
onShare?: (platform: SharePlatform) => void;
}
const PLATFORMS: SharePlatform[] = ['facebook', 'twitter', 'whatsapp', 'telegram', 'email', 'link'];
export function SocialShareButtons({
articleId,
title,
url,
excerpt,
image,
tags,
variant = 'default',
className = '',
onShare,
}: SocialShareButtonsProps) {
const [isTracking, setIsTracking] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const shareData: ShareData = {
title,
url,
excerpt,
image,
tags,
};
const handleShare = async (platform: SharePlatform) => {
try {
// Track the share event
setIsTracking(true);
await trackShare({
articleId,
platform,
userAgent: navigator.userAgent,
// Note: We don't send IP address from frontend for privacy reasons
// Backend should extract it from the request if needed
});
// Call the onShare callback if provided
if (onShare) {
onShare(platform);
}
// Open share URL in new window for social platforms
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error('Failed to track share:', error);
// Still open the share URL even if tracking fails
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsTracking(false);
}
};
const handleCopyLink = async (success: boolean) => {
if (success) {
await handleShare('link');
}
};
// Determine layout based on variant
const getLayoutClasses = () => {
switch (variant) {
case 'compact':
return 'flex items-center space-x-1';
case 'footer':
return 'flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-4';
case 'floating':
return 'fixed right-4 bottom-4 flex flex-col space-y-2 z-50';
default:
return 'flex flex-wrap items-center gap-2';
}
};
const getButtonSize = () => {
switch (variant) {
case 'compact':
return 'sm' as const;
case 'footer':
return 'default' as const;
case 'floating':
return 'default' as const;
default:
return 'default' as const;
}
};
const getButtonVariant = () => {
switch (variant) {
case 'compact':
return 'ghost' as const;
case 'footer':
return 'outline' as const;
case 'floating':
return 'default' as const;
default:
return 'outline' as const;
}
};
const showLabels = variant === 'footer';
// For compact variant, only show a single share button that expands on hover
if (variant === 'compact') {
return (
<div
className={`relative ${className}`}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<div className="flex items-center space-x-1">
<ShareButton
platform="link"
onClick={() => handleShare('link')}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
{isExpanded && (
<div className="flex items-center space-x-1 animate-in slide-in-from-right-2">
{PLATFORMS.filter(p => p !== 'link').map((platform) => (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className={`${getLayoutClasses()} ${className}`}>
{PLATFORMS.map((platform) => {
if (platform === 'link') {
return (
<CopyLinkButton
key={platform}
url={url}
size={getButtonSize()}
variant={getButtonVariant()}
showLabel={showLabels}
onCopy={handleCopyLink}
/>
);
}
return (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
showLabel={showLabels}
/>
);
})}
</div>
);
}
@@ -0,0 +1,4 @@
export { SocialShareButtons } from './SocialShareButtons';
export { ShareButton } from './ShareButton';
export { CopyLinkButton } from './CopyLinkButton';
export type { SocialShareVariant } from './SocialShareButtons';
+141
View File
@@ -0,0 +1,141 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchHeroArticle } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Calendar, User, ArrowRight, Eye } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="border-brutal bg-card p-0 animate-pulse">
<div className="h-80 bg-muted"></div>
<div className="p-8">
<div className="h-10 bg-muted rounded w-3/4 mb-4"></div>
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
<div className="h-20 bg-muted rounded mb-6"></div>
<div className="h-12 bg-muted rounded w-40"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
<Button variant="brutal" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (!article) {
return (
<div className="border-brutal bg-card p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
<span className="font-display text-4xl">?</span>
</div>
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
<p className="font-body text-muted-foreground mb-4">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
This space will showcase your most important story
</div>
</div>
);
}
return (
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
{article.featuredImage && (
<div className="relative overflow-hidden">
<div className="absolute top-0 left-0 z-10">
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
Прекршени Вести
</span>
</div>
<div className="relative h-72 md:h-96 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
</div>
</div>
)}
<div className="p-6 md:p-8">
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h2>
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
<span>{article.views} views</span>
</div>
</div>
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
{article.excerpt}
</p>
)}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{article.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-xs font-body uppercase tracking-wider border-2 border-foreground bg-background"
>
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
<Link to={`/articles/${article.id}`}>
<Button variant="brutalAccent" className="gap-2">
Read Full Story
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
<span className="font-bold text-foreground">
{(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
</span> shares
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,136 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
import { ArrowRight } from 'lucide-react'
export function LatestArticlesGrid() {
const { data, isLoading, error } = useQuery({
queryKey: ['latest-articles'],
queryFn: () => api.fetchLatestArticles(12),
})
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-brutal-sm bg-card p-4 animate-pulse">
<div className="h-40 bg-muted mb-4"></div>
<div className="h-6 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="border-brutal bg-destructive/10 p-8 text-center">
<div className="text-destructive text-2xl font-display mb-2">ГРЕШКА</div>
<p className="font-body text-sm text-destructive">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="font-display text-2xl mb-2">НЕМА СТАТИИ</div>
<p className="font-body text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between border-b-4 border-foreground pb-4">
<h2 className="text-3xl md:text-4xl font-display">Најнови</h2>
<Link
to="/archive"
className="font-body text-sm uppercase tracking-wider border-2 border-foreground px-4 py-2 hover:bg-accent hover:border-accent transition-all duration-150 flex items-center gap-2"
>
Сите
<ArrowRight className="w-4 h-4" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article, index) => (
<article
key={article.id}
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
>
<Link
to={`/articles/${article.id}`}
className="block"
>
{article.featuredImage ? (
<div className="relative h-40 overflow-hidden border-b-2 border-foreground">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors duration-300" />
</div>
) : (
<div className="h-40 bg-secondary border-b-2 border-foreground flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)', backgroundSize: '10px 10px' }}></div>
<span className="font-display text-4xl text-foreground/30">N</span>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-display leading-tight mb-2 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-xs font-body line-clamp-2 mb-3">
{article.excerpt}
</p>
)}
</div>
</Link>
<div className="px-4 pb-4 border-t-2 border-foreground/10 pt-3 mt-auto">
<div className="flex items-center justify-between font-body text-xs uppercase tracking-wider text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
})}
</span>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
>
{article.category.name}
</Link>
)}
</div>
<div className="mt-3">
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
</article>
))}
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
export function PinnedLiveBlogsSidebar() {
const { data: liveBlogs, isLoading, error } = useQuery({
queryKey: ['pinned-live-blogs'],
queryFn: fetchPinnedLiveBlogs,
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'live':
return (
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
LIVE
</span>
);
case 'ended':
return (
<span className="px-2 py-0.5 border-2 border-foreground/40 text-foreground/40 text-xs font-body font-bold uppercase">
ENDED
</span>
);
case 'archived':
return (
<span className="px-2 py-0.5 border-2 border-foreground/30 text-foreground/30 text-xs font-body font-bold uppercase">
ARCHIVED
</span>
);
default:
return (
<span className="px-2 py-0.5 border-2 border-foreground text-foreground text-xs font-body font-bold uppercase">
DRAFT
</span>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
});
};
if (isLoading) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-4">
<div className="text-destructive font-body text-sm mb-2">ERROR</div>
<Button variant="brutal" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
if (!liveBlogs || liveBlogs.length === 0) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-6 border-2 border-dashed border-foreground/20 p-4">
<div className="font-body text-muted-foreground mb-2">No pinned live blogs</div>
<p className="font-body text-xs text-muted-foreground">
Pin live blogs from the admin panel.
</p>
</div>
</div>
);
}
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-accent" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
{liveBlogs.length}
</span>
</div>
<div className="space-y-4">
{liveBlogs.map((liveBlog) => (
<Link
key={liveBlog.id}
to="/live-blogs/$slug"
params={{ slug: liveBlog.slug }}
className="block group"
>
<div className="p-4 border-2 border-foreground/10 hover:border-foreground hover:shadow-brutal-sm transition-all duration-150 group-hover:-translate-y-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-body text-sm font-bold leading-tight line-clamp-2 group-hover:text-accent transition-colors">
{liveBlog.title}
</h4>
{getStatusBadge(liveBlog.status)}
</div>
{liveBlog.description && (
<p className="text-xs font-body text-muted-foreground mb-3 line-clamp-2">
{liveBlog.description}
</p>
)}
<div className="flex flex-wrap items-center gap-3 text-xs font-body text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(liveBlog.createdAt)}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{liveBlog.viewCount}</span>
</div>
{liveBlog.updates && liveBlog.updates.length > 0 && (
<div className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
<span>{liveBlog.updates.length}</span>
</div>
)}
{liveBlog.author && (
<div className="text-xs text-muted-foreground">
{liveBlog.author.name}
</div>
)}
</div>
{liveBlog.featuredImage && (
<div className="mt-3">
<div className="relative h-20 border-2 border-foreground/10 overflow-hidden">
<img
src={liveBlog.featuredImage}
alt={liveBlog.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
</div>
</div>
)}
</div>
</Link>
))}
</div>
<div className="mt-6 pt-4 border-t-2 border-foreground/10">
<Link to="/live-blogs" className="block">
<Button variant="brutal" className="w-full justify-center">
Сите Live
</Button>
</Link>
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { ThemeToggle } from './ThemeToggle';
import { Menu, X, Zap } from 'lucide-react';
const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември'];
const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела'];
const formatDateMk = () => {
const d = new Date();
return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`;
};
export function Header() {
const { user, logout, isAuthenticated, hasRole } = useAuth();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
const navLinks = [
{ to: '/', label: 'Почетна' },
{ to: '/sport', label: 'Спорт' },
{ to: '/art', label: 'Уметност' },
{ to: '/science', label: 'Наука' },
{ to: '/archive', label: 'Архива' },
{ to: '/live-blogs', label: 'LIVE' },
];
const adminLinks = [
{ to: '/admin', label: 'Admin' },
{ to: '/admin/live-blogs/create', label: '+ New Live' },
];
return (
<header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]">
<div className="border-b-2 border-foreground/20">
<div className="container mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between py-2">
<div className="hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-muted-foreground">
<Zap className="w-3 h-3" />
<span>Сатирични вести од Македонија</span>
</div>
<div className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
{formatDateMk()}
</div>
</div>
</div>
</div>
<div className="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="group">
<h1 className="text-4xl md:text-5xl font-display tracking-tight">
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-150">c</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-200">e</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-250">b</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-300">o</span>
<span className="text-accent inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-350">.</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1 delay-400">m</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-500">k</span>
</h1>
</Link>
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link, index) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-transparent hover:border-foreground hover:bg-accent hover:text-foreground transition-all duration-150"
style={{ animationDelay: `${index * 50}ms` }}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="px-3 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent transition-all duration-150"
>
{link.label}
</Link>
))}
</>
)}
<div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username}
</span>
<Button
variant="brutal"
size="sm"
onClick={logout}
>
OUT
</Button>
</div>
</>
) : (
<Link to="/auth" className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 ml-2">
Login
</Link>
)}
<div className="ml-4 pl-4 border-l-2 border-foreground/20 flex items-center gap-3">
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="hidden lg:block border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-3 py-1 font-body text-xs font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
<ThemeToggle />
</div>
</div>
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<Button
variant="brutal"
size="icon"
onClick={toggleMobileMenu}
className="h-10 w-10"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{isMobileMenuOpen && (
<div className="md:hidden mt-4 pt-4 border-t-4 border-foreground animate-scale-in">
<div className="flex flex-col gap-2">
{navLinks.map((link, index) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground hover:bg-accent hover:border-accent transition-all duration-150"
style={{ animationDelay: `${index * 50}ms` }}
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<div className="pt-3 mt-2 border-t-4 border-foreground">
<p className="font-body text-xs uppercase tracking-wider text-muted-foreground mb-2 pl-2">Admin</p>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent hover:bg-foreground hover:text-accent transition-all duration-150 block"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
</div>
)}
<div className="pt-3 mt-2 border-t-4 border-foreground">
<div className="flex items-center justify-between p-2">
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username}
</span>
<Button
variant="brutal"
size="sm"
onClick={() => {
logout();
closeMobileMenu();
}}
>
OUT
</Button>
</div>
</div>
</>
) : (
<Link
to="/auth"
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 mt-2"
onClick={closeMobileMenu}
>
Login
</Link>
)}
</div>
</div>
)}
</div>
</header>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { getInitialTheme, toggleTheme, watchSystemTheme } from '@/lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(getInitialTheme);
useEffect(() => {
// Watch for system theme changes
const cleanup = watchSystemTheme((newTheme) => {
setTheme(newTheme);
});
return cleanup;
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className="h-9 w-9"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</Button>
);
}
@@ -0,0 +1,539 @@
import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{
type: 'article' | 'liveBlog';
id: string;
title: string;
} | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
limit: 50,
status: showArchived ? 'archived' : 'draft,live,ended'
});
const { data: articlesData, isLoading: loadingArticles } = useArticles({
limit: 50,
status: showArchived ? 'archived' : 'draft,published'
});
const deleteArticleMutation = useDeleteArticle();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const archiveArticleMutation = useArchiveArticle();
const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog();
const updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || [];
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('delete');
setShowConfirmDialog(true);
};
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('archive');
setShowConfirmDialog(true);
};
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
setIsProcessing(true);
try {
if (type === 'article') {
await publishArticleMutation.mutateAsync({ id, status: 'published' });
} else {
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
}
} catch (error) {
console.error('Failed to publish:', error);
} finally {
setIsProcessing(false);
}
};
const handleConfirmAction = async () => {
if (!itemToDelete) return;
setIsProcessing(true);
try {
if (dialogType === 'delete') {
if (itemToDelete.type === 'article') {
await deleteArticleMutation.mutateAsync(itemToDelete.id);
} else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
}
} else {
if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else {
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
}
}
setShowConfirmDialog(false);
setItemToDelete(null);
} catch (error) {
console.error(`Failed to ${dialogType}:`, error);
} finally {
setIsProcessing(false);
}
};
const handleCancelAction = () => {
setShowConfirmDialog(false);
setItemToDelete(null);
};
const handleSetHero = async (articleId: string, isHero: boolean) => {
setIsProcessing(true);
try {
await updateArticleMutation.mutateAsync({
id: articleId,
dto: { isHero }
});
} catch (error) {
console.error('Failed to update hero status:', error);
} finally {
setIsProcessing(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'published':
case 'live':
return 'bg-green-500 text-white border-2 border-foreground';
case 'draft':
return 'bg-yellow-400 text-black border-2 border-foreground';
case 'archived':
case 'ended':
return 'bg-gray-400 text-white border-2 border-foreground';
default:
return 'bg-blue-500 text-white border-2 border-foreground';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'published': return 'Објавено';
case 'draft': return 'Нацрт';
case 'archived': return 'Архивирано';
case 'live': return 'Во живо';
case 'ended': return 'Завршено';
default: return status;
}
};
return (
<div className="space-y-8">
<div className="border-b-4 border-foreground pb-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div>
<h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
<p className="font-body text-muted-foreground mt-1">
Управување со сите написи и live блогови
</p>
</div>
<div className="flex gap-3 flex-wrap">
<Button
variant={showArchived ? 'brutal' : 'brutalOutline'}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="brutalAccent">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild variant="brutalOutline">
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
</div>
</div>
{!showArchived && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="border-brutal bg-card">
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<h2 className="text-2xl font-display uppercase">
{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
</h2>
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{liveBlogs.length || 0}
</span>
</div>
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
{loadingLiveBlogs ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p>
</div>
) : (
liveBlogs.map((blog) => (
<div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Link
to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }}
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
>
{blog.title}
</Link>
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
{getStatusText(blog.status)}
</span>
{blog.isPinned && (
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
Закачено
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {blog.slug}</span>
<span>•</span>
<span>{format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
<span>•</span>
<span>Прегледи: {blog.viewCount}</span>
</div>
</div>
<div className="flex gap-1 flex-shrink-0">
<Button asChild size="sm" variant="brutalOutline">
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
</Button>
{showArchived ? (
<Button
size="sm"
variant="brutalOutline"
onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="brutalOutline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
))
)}
</div>
</div>
<div className="border-brutal bg-card">
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<h2 className="text-2xl font-display uppercase">
{showArchived ? 'Архивирани написи' : 'Написи'}
</h2>
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{articles.length || 0}
</span>
</div>
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
{loadingArticles ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div>
) : articles.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p>
</div>
) : (
articles.map((article) => (
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
>
{article.title}
</Link>
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
{getStatusText(article.status)}
</span>
{article.isHero && (
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
★ Hero
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {article.slug}</span>
<span>•</span>
<span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
<span>•</span>
<span>Прегледи: {article.views}</span>
<span>•</span>
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
</div>
{article.excerpt && (
<p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
{article.excerpt}
</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0 flex-col">
<div className="flex gap-1">
<Button asChild size="sm" variant="brutalOutline">
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
</Button>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={article.isHero ? 'brutal' : 'brutalOutline'}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set Hero'}
</Button>
{showArchived ? (
<Button
size="sm"
variant="brutalOutline"
onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="brutalOutline"
onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
{!showArchived && (
<div className="border-brutal bg-card p-6">
<h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) =>
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
</div>
</div>
<div>
<h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
<div className="space-y-3">
{articles
.filter(a => a.status === 'published')
.sort((a, b) => {
const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0);
const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
(b.whatsappShares || 0) + (b.telegramShares || 0);
return bShares - aShares;
})
.slice(0, 5)
.map((article) => {
const totalShares = (article.facebookShares || 0) +
(article.twitterShares || 0) +
(article.whatsappShares || 0) +
(article.telegramShares || 0);
const shareRate = article.views > 0
? ((totalShares / article.views) * 100).toFixed(2)
: '0.00';
return (
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-display text-lg hover:underline hover:text-accent-foreground"
>
{article.title}
</Link>
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
<span>Views: {article.views}</span>
<span>•</span>
<span>Shares: {totalShares}</span>
<span>•</span>
<span>Share Rate: {shareRate}%</span>
</div>
<div className="flex items-center gap-4 mt-2 text-xs flex-wrap">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-blue-600"></span>
FB: {article.facebookShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-sky-500"></span>
X: {article.twitterShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500"></span>
WA: {article.whatsappShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-cyan-500"></span>
TG: {article.telegramShares || 0}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
<h3 className="text-2xl font-display uppercase mb-3">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3>
<p className="font-body text-muted-foreground mb-6">
{dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p>
<div className="flex justify-end gap-3">
<Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи
</Button>
<Button
variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
onClick={handleConfirmAction}
disabled={isProcessing}
>
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArchiveComponent() {
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) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<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>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} views</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</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,11 @@
import { CategoryPage } from './CategoryPage'
export function ArtComponent() {
return (
<CategoryPage
categorySlug="art"
categoryName="Уметност"
categoryDescription="Уметност, култура и забава"
/>
)
}
@@ -0,0 +1,246 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
import { CommentSection } from '@/components/features/comments/CommentSection'
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
import { SocialShareButtons } from '@/components/features/social-share'
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>
{/* Social Sharing */}
<div className="mb-8 flex flex-wrap items-center gap-4">
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
/>
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
</div>
{data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${
data.imagePosition === 'top'
? 'w-full mb-8'
: data.imagePosition === 'left'
? 'float-none md:float-left mr-0 md:mr-6'
: 'float-none md:float-right ml-0 md:ml-6'
}`}>
<img
src={data.featuredImage}
alt={data.title}
className={`rounded-xl object-cover ${
data.imagePosition === 'top'
? data.imageSize === 'small'
? 'h-32'
: data.imageSize === 'medium'
? 'h-48'
: 'h-64 md:h-96'
: data.imageSize === 'small'
? 'w-full md:w-48 h-32'
: data.imageSize === 'medium'
? 'w-full md:w-64 h-48'
: 'w-full md:w-96 h-64'
}`}
onError={(e) => {
console.error('Failed to load image:', data.featuredImage, e);
e.currentTarget.style.display = 'none';
// Show fallback
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/>
<div
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
style={{ display: 'none' }}
>
<span>Image not available</span>
</div>
</div>
)}
{/* Video rendering */}
{data.videoUrl && data.videoPosition !== 'none' && (
<div className={getVideoPositionClasses(data.videoPosition)}>
<YouTubeEmbed
url={data.videoUrl}
title={data.title}
caption={data.videoCaption}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
)}
<div className="prose prose-slate max-w-none">
<div className="text-lg leading-relaxed mb-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: (props) => (
<img
{...props}
className="max-w-full h-auto rounded-lg my-4"
alt={props.alt || 'Article image'}
/>
),
a: (props) => {
// Check if the link is a YouTube URL
const videoId = extractYouTubeVideoId(props.href || '');
if (videoId) {
return (
<div className="my-6">
<YouTubeEmbed
url={props.href || ''}
title={props.title || 'YouTube video'}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
);
}
// Regular link
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
}
}}
>
{data.content}
</ReactMarkdown>
</div>
</div>
{/* Social Sharing Footer */}
<div className="mt-8 pt-8 border-t">
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-muted-foreground mb-2">Share this article:</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
variant="footer"
/>
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
</div>
</div>
</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>
)}
{/* Reactions */}
<div className="mt-8 pt-8 border-t">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
<ReactionButtons articleId={data.id} />
</div>
</div>
{/* Comments */}
<CommentSection articleId={data.id} />
</article>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { LoginForm } from '../auth/LoginForm';
import { RegisterForm } from '../auth/RegisterForm';
import { useAuth } from '../../contexts/AuthContext';
import { Navigate } from '@tanstack/react-router';
export function AuthPage() {
const { isAuthenticated } = useAuth();
const [isLogin, setIsLogin] = useState(true);
if (isAuthenticated) {
return <Navigate to="/" />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-lg">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">
Welcome to <span className="text-primary">Placebo.mk</span>
</h1>
<p className="text-muted-foreground">
{isLogin
? 'Login to comment, react, and access admin features'
: 'Join our community of sarcastic news enthusiasts'}
</p>
</div>
{isLogin ? (
<LoginForm
onSuccess={() => window.location.href = '/'}
onSwitchToRegister={() => setIsLogin(false)}
/>
) : (
<RegisterForm
onSuccess={() => window.location.href = '/'}
onSwitchToLogin={() => setIsLogin(true)}
/>
)}
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
and{' '}
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
</p>
<p className="mt-2">
Need help?{' '}
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
Contact support
</a>
</p>
</div>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
interface CategoryPageProps {
categorySlug: string
categoryName: string
categoryDescription?: string
}
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
queryKey: ['category-articles', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
})
const { data: heroData, isLoading: heroLoading } = useQuery({
queryKey: ['category-hero', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
enabled: !!categorySlug,
})
const heroArticle = heroData?.data?.[0]
const articles = articlesData?.data || []
if (articlesLoading || heroLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Вчитување...</div>
</div>
)
}
if (articlesError) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Category Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold">{categoryName}</h1>
{categoryDescription && (
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
)}
</div>
{/* Hero Section with Pinned Live Blogs Sidebar */}
{heroArticle && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
{/* Hero Article - 2/3 width */}
<div className="lg:col-span-2">
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
<Link to={`/articles/${heroArticle.id}`} className="block">
{heroArticle.featuredImage ? (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={heroArticle.featuredImage}
alt={heroArticle.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
) : (
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
</Link>
<div className="p-6">
<Link to={`/articles/${heroArticle.id}`} className="block">
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
{heroArticle.title}
</h2>
{heroArticle.excerpt && (
<p className="text-muted-foreground mb-4 line-clamp-3">
{heroArticle.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{heroArticle.views} прегледи</span>
</div>
<SocialShareButtons
articleId={heroArticle.id}
title={heroArticle.title}
url={`${window.location.origin}/articles/${heroArticle.id}`}
excerpt={heroArticle.excerpt}
image={heroArticle.featuredImage}
tags={heroArticle.tags}
variant="compact"
/>
</div>
</div>
</div>
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
)}
{/* Articles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{articles.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Нема објавени статии во оваа категорија. Проверете подоцна!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,39 @@
import { CreateLiveBlog } from '@/components/admin/live-blog/CreateLiveBlog';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Link } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
export function CreateLiveBlogComponent() {
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<Link to="/live-blogs">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Live Blogs
</Button>
</Link>
</div>
<CreateLiveBlog
onSuccess={() => {
// Success is handled by navigation in the component
}}
/>
<Card className="mt-6">
<CardContent className="p-6">
<h3 className="font-medium mb-2">Tips for creating a successful live blog:</h3>
<ul className="text-sm text-muted-foreground space-y-1 list-disc pl-5">
<li>Choose a clear, descriptive title that tells readers what to expect</li>
<li>Use a URL-friendly slug (lowercase, hyphens, no spaces)</li>
<li>Start with a draft status to prepare content before going live</li>
<li>Use the description to provide context for readers</li>
<li>Remember: you can always edit these details later</li>
</ul>
</CardContent>
</Card>
</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,105 @@
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({
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 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>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data?.data.map((liveBlog) => (
<Link
key={liveBlog.id}
to="/live-blogs/$slug"
params={{ slug: 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,11 @@
import { CategoryPage } from './CategoryPage'
export function ScienceComponent() {
return (
<CategoryPage
categorySlug="science"
categoryName="Наука"
categoryDescription="Научни откритија и технологија"
/>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function SportComponent() {
return (
<CategoryPage
categorySlug="sport"
categoryName="Спорт"
categoryDescription="Спортски вести и анализи"
/>
)
}
+69
View File
@@ -0,0 +1,69 @@
/* eslint-disable react-refresh/only-export-components */
import type { Article } from '@/lib/api';
interface SocialMetaTagsProps {
article: Article;
url?: string;
}
export function SocialMetaTags({ article, url }: SocialMetaTagsProps) {
// This component doesn't render anything directly
// It's used to generate meta tags for TanStack Router's head API
// Mark props as used to avoid ESLint warnings
void article;
void url;
return null;
}
export function getSocialMetaTags(article: Article, url?: string) {
// Use article's social metadata if available, otherwise generate from article data
const ogTitle = article.ogTitle || article.title;
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk';
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg';
const twitterTitle = article.twitterTitle || article.title;
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk';
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg';
// Use provided URL or construct from article ID
const articleUrl = url || `${typeof window !== 'undefined' ? window.location.origin : ''}/articles/${article.id}`;
const metaTags = [
// Basic SEO
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
// Open Graph tags
{ property: 'og:title', content: ogTitle },
{ property: 'og:description', content: ogDescription },
{ property: 'og:image', content: ogImage },
{ property: 'og:url', content: articleUrl },
{ property: 'og:type', content: 'article' },
{ property: 'og:locale', content: 'mk_MK' },
// Twitter Card tags
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
// Article-specific tags
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
];
// Add author if available
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name });
}
// Add tags if available
if (article.tags && article.tags.length > 0) {
article.tags.forEach(tag => {
metaTags.push({ property: 'article:tag', content: tag });
});
}
return {
meta: metaTags,
};
}
+21
View File
@@ -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 }
+30
View File
@@ -0,0 +1,30 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 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-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
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",
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
},
size: {
default: "h-11 px-6 py-2",
sm: "h-9 px-4 py-1",
lg: "h-14 px-8 text-base",
icon: "h-12 w-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"border-brutal bg-card text-card-foreground",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-display leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm font-body text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+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 }
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}
+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 }
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
interface YouTubeEmbedProps {
/** YouTube video URL or video ID */
url: string;
/** Video title for accessibility */
title?: string;
/** Optional caption below the video */
caption?: string;
/** Additional CSS classes */
className?: string;
/** Autoplay the video (muted on mobile) */
autoplay?: boolean;
/** Show video controls */
controls?: boolean;
/** Reduce YouTube branding */
modestbranding?: boolean;
/** Show related videos at the end */
showRelated?: boolean;
/** Start time in seconds */
startTime?: number;
/** End time in seconds */
endTime?: number;
/** Show loading state */
showLoading?: boolean;
}
export function YouTubeEmbed({
url,
title = 'YouTube video',
caption,
className,
autoplay = false,
controls = true,
modestbranding = true,
showRelated = false,
startTime,
endTime,
showLoading = true,
}: YouTubeEmbedProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
// Extract video ID from URL
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
return (
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
<p className="text-destructive text-sm">
Invalid YouTube URL: {url}
</p>
</div>
);
}
// Generate embed URL with options
const embedUrl = generateYouTubeEmbedUrl(videoId, {
autoplay,
controls,
modestbranding,
rel: showRelated,
playsinline: true,
start: startTime,
end: endTime,
});
return (
<div className={cn('w-full', className)}>
{/* Video container with aspect ratio */}
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
{/* Loading state */}
{showLoading && !isLoaded && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{/* Error state */}
{hasError && (
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
<div className="text-destructive mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-destructive font-medium">Video failed to load</p>
<p className="text-muted-foreground text-sm mt-1">
The YouTube video could not be loaded. Please check the URL or try again later.
</p>
</div>
)}
{/* YouTube iframe */}
{!hasError && (
<iframe
className={cn(
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
!isLoaded && 'opacity-0'
)}
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
)}
</div>
{/* Caption */}
{caption && (
<div className="mt-2 text-sm text-muted-foreground text-center">
{caption}
</div>
)}
</div>
);
}