social share up and running
This commit is contained in:
1 parent
000ebd388a
commit
b8779e5a35
22 files changed
+1935
-41
No files matched your search
@@ -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';
|
||||
@@ -324,6 +324,11 @@ export function AdminDashboardComponent() {
|
||||
</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 text-muted-foreground line-clamp-2">
|
||||
@@ -414,9 +419,142 @@ export function AdminDashboardComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{/* Social Media Analytics - Only show when not viewing archived items */}
|
||||
{!showArchived && (
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Social Media Analytics</CardTitle>
|
||||
<CardDescription>Share statistics for articles</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Total Shares Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Facebook Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Twitter Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">WhatsApp Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Telegram Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{articles.reduce((sum, a) =>
|
||||
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Shared Articles */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold 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 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<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">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
Facebook: {article.facebookShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-sky-500 rounded-full"></span>
|
||||
Twitter: {article.twitterShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
WhatsApp: {article.whatsappShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-cyan-500 rounded-full"></span>
|
||||
Telegram: {article.telegramShares || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ 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({
|
||||
@@ -69,9 +70,21 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
<span>By {data.author.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||
{/* Social Sharing */}
|
||||
<div className="mb-8">
|
||||
<SocialShareButtons
|
||||
articleId={data.id}
|
||||
title={data.title}
|
||||
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||
excerpt={data.excerpt}
|
||||
image={data.featuredImage}
|
||||
tags={data.tags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||
<div className={`relative mb-4 ${
|
||||
data.imagePosition === 'top'
|
||||
? 'w-full mb-8'
|
||||
@@ -165,10 +178,26 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||
{/* Social Sharing Footer */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">Share this article:</p>
|
||||
<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"
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 ArticlesComponent() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -31,34 +32,52 @@ export function ArticlesComponent() {
|
||||
<p className="text-muted-foreground">Latest news and articles</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data?.data.map((article) => (
|
||||
<Link
|
||||
key={article.id}
|
||||
to={`/articles/${article.id}`}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
|
||||
import { trackShare } from '@/lib/analytics';
|
||||
|
||||
interface UseSocialShareOptions {
|
||||
onSuccess?: (platform: SharePlatform) => void;
|
||||
onError?: (platform: SharePlatform, error: Error) => void;
|
||||
onCopySuccess?: () => void;
|
||||
onCopyError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseSocialShareReturn {
|
||||
isSharing: boolean;
|
||||
isCopying: boolean;
|
||||
lastSharedPlatform: SharePlatform | null;
|
||||
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
|
||||
copyLink: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
|
||||
|
||||
const share = useCallback(async (
|
||||
platform: SharePlatform,
|
||||
data: ShareData & { articleId: string }
|
||||
) => {
|
||||
setIsSharing(true);
|
||||
setLastSharedPlatform(platform);
|
||||
|
||||
try {
|
||||
// Track the share event
|
||||
await trackShare({
|
||||
articleId: data.articleId,
|
||||
platform,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
// Call success callback
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(platform);
|
||||
}
|
||||
|
||||
// Open share URL for social platforms (not for 'link')
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, data);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to share on ${platform}:`, error);
|
||||
|
||||
// Call error callback
|
||||
if (options.onError) {
|
||||
options.onError(platform, error as Error);
|
||||
}
|
||||
|
||||
// Still open the share URL even if tracking fails (for social platforms)
|
||||
if (platform !== 'link') {
|
||||
const shareUrl = getShareUrl(platform, data);
|
||||
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const copyLink = useCallback(async (url: string): Promise<boolean> => {
|
||||
setIsCopying(true);
|
||||
|
||||
try {
|
||||
const success = await copyToClipboard(url);
|
||||
|
||||
if (success && options.onCopySuccess) {
|
||||
options.onCopySuccess();
|
||||
} else if (!success && options.onCopyError) {
|
||||
options.onCopyError(new Error('Failed to copy to clipboard'));
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
|
||||
if (options.onCopyError) {
|
||||
options.onCopyError(error as Error);
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
isSharing,
|
||||
isCopying,
|
||||
lastSharedPlatform,
|
||||
share,
|
||||
copyLink,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type SharePlatform } from './social-utils';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
export interface TrackShareParams {
|
||||
articleId: string;
|
||||
platform: SharePlatform;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to track share event:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error tracking share event:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getShareStats = async (articleId?: string) => {
|
||||
try {
|
||||
const url = articleId
|
||||
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
|
||||
: `${API_BASE_URL}/api/v1/analytics/shares`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // Include cookies for admin auth
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching share stats:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTopSharedArticles = async (limit: number = 10) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching top shared articles:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTotalShareStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching total share stats:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
|
||||
|
||||
export interface ShareData {
|
||||
title: string;
|
||||
url: string;
|
||||
excerpt?: string;
|
||||
image?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const getShareUrl = (
|
||||
platform: Exclude<SharePlatform, 'link'>,
|
||||
data: ShareData
|
||||
): string => {
|
||||
const { title, url, excerpt } = data;
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
|
||||
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
|
||||
case 'twitter':
|
||||
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
|
||||
case 'whatsapp':
|
||||
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
|
||||
case 'telegram':
|
||||
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
|
||||
case 'email':
|
||||
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
|
||||
default:
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return success;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatformIcon = (platform: SharePlatform): string => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatformLabel = (platform: SharePlatform): string => {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'Facebook';
|
||||
case 'twitter':
|
||||
return 'Twitter';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'telegram':
|
||||
return 'Telegram';
|
||||
case 'email':
|
||||
return 'Email';
|
||||
case 'link':
|
||||
return 'Copy Link';
|
||||
default:
|
||||
return 'Share';
|
||||
}
|
||||
};
|
||||
|
||||
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
|
||||
const { title, excerpt, image, url } = data;
|
||||
|
||||
// Default values if not provided
|
||||
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
|
||||
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
|
||||
const ogImage = image || '/placeholder-image.jpg';
|
||||
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
|
||||
return {
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage,
|
||||
ogUrl,
|
||||
ogType: 'article' as const,
|
||||
twitterCard: 'summary_large_image' as const,
|
||||
twitterTitle: ogTitle,
|
||||
twitterDescription: ogDescription,
|
||||
twitterImage: ogImage,
|
||||
};
|
||||
};
|
||||
|
||||
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
};
|
||||
@@ -199,6 +199,75 @@ const articleDetailRoute = createRoute({
|
||||
const { id } = articleDetailRoute.useParams()
|
||||
return <ArticleDetailComponent id={id} />
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
// Fetch article data for meta tags
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
|
||||
if (!response.ok) {
|
||||
return { article: null }
|
||||
}
|
||||
const data = await response.json()
|
||||
return { article: data.data }
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const article = loaderData?.article
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
meta: [
|
||||
{ title: 'Article Not Found - Placebo.mk' },
|
||||
{ name: 'description', content: 'Article not found' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
|
||||
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: typeof window !== 'undefined' ? window.location.href : '' },
|
||||
{ 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const liveBlogsRoute = createRoute({
|
||||
|
||||
Reference in new issue
Block a user