social share up and running

This commit is contained in:
dimitar committed 2026-02-05 03:04:19 +01:00
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,
};
}
+101
View File
@@ -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,
};
}
+96
View File
@@ -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;
}
};
+124
View File
@@ -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) + '...';
};
+69
View File
@@ -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({