From b8779e5a35353be327428623e67dfe5fdf324da6 Mon Sep 17 00:00:00 2001 From: echo Date: Thu, 5 Feb 2026 03:04:19 +0100 Subject: [PATCH] social share up and running --- backend/src/app.module.ts | 4 + .../modules/analytics/analytics.controller.ts | 76 ++++ .../src/modules/analytics/analytics.dto.ts | 47 +++ .../src/modules/analytics/analytics.entity.ts | 57 +++ .../src/modules/analytics/analytics.module.ts | 14 + .../modules/analytics/analytics.service.ts | 266 +++++++++++++ backend/src/modules/articles.dto.ts | 48 +++ backend/src/modules/articles.dto.ts.backup | 356 ++++++++++++++++++ backend/src/modules/entities.ts | 30 ++ .../features/social-share/CopyLinkButton.tsx | 75 ++++ .../features/social-share/ShareButton.tsx | 89 +++++ .../social-share/SocialShareButtons.tsx | 188 +++++++++ .../components/features/social-share/index.ts | 4 + .../routes/AdminDashboardComponent.tsx | 142 ++++++- .../routes/ArticleDetailComponent.tsx | 39 +- .../components/routes/ArticlesComponent.tsx | 75 ++-- .../src/components/seo/SocialMetaTags.tsx | 69 ++++ frontend/src/hooks/useSocialShare.ts | 101 +++++ frontend/src/lib/analytics.ts | 96 +++++ frontend/src/lib/social-utils.ts | 124 ++++++ frontend/src/routes.tsx | 69 ++++ todos.md | 7 +- 22 files changed, 1935 insertions(+), 41 deletions(-) create mode 100644 backend/src/modules/analytics/analytics.controller.ts create mode 100644 backend/src/modules/analytics/analytics.dto.ts create mode 100644 backend/src/modules/analytics/analytics.entity.ts create mode 100644 backend/src/modules/analytics/analytics.module.ts create mode 100644 backend/src/modules/analytics/analytics.service.ts create mode 100644 backend/src/modules/articles.dto.ts.backup create mode 100644 frontend/src/components/features/social-share/CopyLinkButton.tsx create mode 100644 frontend/src/components/features/social-share/ShareButton.tsx create mode 100644 frontend/src/components/features/social-share/SocialShareButtons.tsx create mode 100644 frontend/src/components/features/social-share/index.ts create mode 100644 frontend/src/components/seo/SocialMetaTags.tsx create mode 100644 frontend/src/hooks/useSocialShare.ts create mode 100644 frontend/src/lib/analytics.ts create mode 100644 frontend/src/lib/social-utils.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e5d80a3..6e59ce3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { LiveBlogModule } from './modules/live-blog.module'; import { UserModule } from './modules/users/user.module'; import { AuthModule } from './modules/auth/auth.module'; import { CommentModule } from './modules/comment/comment.module'; +import { AnalyticsModule } from './modules/analytics/analytics.module'; import { Article, Author, @@ -19,6 +20,7 @@ import { Comment, Reaction, } from './modules/entities'; +import { ShareEvent } from './modules/analytics/analytics.entity'; @Module({ imports: [ @@ -41,6 +43,7 @@ import { User, Comment, Reaction, + ShareEvent, ], synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', logging: process.env.DATABASE_LOGGING === 'true', @@ -51,6 +54,7 @@ import { UserModule, AuthModule, CommentModule, + AnalyticsModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/modules/analytics/analytics.controller.ts b/backend/src/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000..a68eaaf --- /dev/null +++ b/backend/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Post, + Body, + Get, + Query, + UsePipes, + ValidationPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { + TrackShareDto, + GetShareStatsDto, + ShareStatsResponse, +} from './analytics.dto'; +import { Public } from '../auth/public.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { UserRole } from '../entities'; + +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Public() + @Post('share') + @HttpCode(HttpStatus.CREATED) + @UsePipes(new ValidationPipe({ transform: true })) + async trackShare(@Body() trackShareDto: TrackShareDto) { + return await this.analyticsService.trackShare(trackShareDto); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('shares') + @UsePipes(new ValidationPipe({ transform: true })) + async getShareStats( + @Query() getShareStatsDto: GetShareStatsDto, + ): Promise { + return await this.analyticsService.getShareStats(getShareStatsDto); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('shares/top') + async getTopSharedArticles(@Query('limit') limit: string) { + const limitNum = limit ? parseInt(limit) : 10; + return await this.analyticsService.getTopSharedArticles(limitNum); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('shares/trends') + async getShareTrends( + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + @Query('interval') interval: 'day' | 'week' | 'month', + ) { + return await this.analyticsService.getShareTrends( + new Date(startDate), + new Date(endDate), + interval || 'day', + ); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('shares/total') + async getTotalShareStats() { + return await this.analyticsService.getTotalShareStats(); + } +} diff --git a/backend/src/modules/analytics/analytics.dto.ts b/backend/src/modules/analytics/analytics.dto.ts new file mode 100644 index 0000000..830286c --- /dev/null +++ b/backend/src/modules/analytics/analytics.dto.ts @@ -0,0 +1,47 @@ +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import type { SharePlatform } from './analytics.entity'; + +export class TrackShareDto { + @IsUUID() + articleId: string; + + @IsEnum(['facebook', 'twitter', 'whatsapp', 'telegram', 'link']) + platform: SharePlatform; + + @IsOptional() + @IsString() + userAgent?: string; + + @IsOptional() + @IsString() + ipAddress?: string; +} + +export class GetShareStatsDto { + @IsOptional() + @IsUUID() + articleId?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; +} + +export class ShareStatsResponse { + articleId: string; + articleTitle: string; + facebookShares: number; + twitterShares: number; + whatsappShares: number; + telegramShares: number; + linkShares: number; + totalShares: number; + views: number; + shareRate: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/src/modules/analytics/analytics.entity.ts b/backend/src/modules/analytics/analytics.entity.ts new file mode 100644 index 0000000..b8180c0 --- /dev/null +++ b/backend/src/modules/analytics/analytics.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Article } from '../entities'; + +export type SharePlatform = + | 'facebook' + | 'twitter' + | 'whatsapp' + | 'telegram' + | 'link'; + +@Entity('share_events') +export class ShareEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + articleId: string; + + @Column({ type: 'text' }) + platform: SharePlatform; + + @Column({ nullable: true }) + userId: string; + + @Column({ nullable: true }) + userAgent: string; + + @Column({ nullable: true }) + ipAddress: string; + + @CreateDateColumn() + createdAt: Date; + + @ManyToOne(() => Article, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'articleId' }) + article: Article; +} + +export interface ShareStats { + articleId: string; + articleTitle: string; + facebookShares: number; + twitterShares: number; + whatsappShares: number; + telegramShares: number; + linkShares: number; + totalShares: number; + views: number; + shareRate: number; +} diff --git a/backend/src/modules/analytics/analytics.module.ts b/backend/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..1d9873f --- /dev/null +++ b/backend/src/modules/analytics/analytics.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { ShareEvent } from './analytics.entity'; +import { Article } from '../entities'; + +@Module({ + imports: [TypeOrmModule.forFeature([ShareEvent, Article])], + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/backend/src/modules/analytics/analytics.service.ts b/backend/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..15436f2 --- /dev/null +++ b/backend/src/modules/analytics/analytics.service.ts @@ -0,0 +1,266 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ShareEvent, SharePlatform } from './analytics.entity'; +import { Article } from '../entities'; +import { + TrackShareDto, + GetShareStatsDto, + ShareStatsResponse, +} from './analytics.dto'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor( + @InjectRepository(ShareEvent) + private readonly shareEventRepository: Repository, + @InjectRepository(Article) + private readonly articleRepository: Repository
, + ) {} + + async trackShare(trackShareDto: TrackShareDto): Promise { + const shareEvent = this.shareEventRepository.create(trackShareDto); + + // Also update the article's share counters + await this.incrementArticleShareCounter( + trackShareDto.articleId, + trackShareDto.platform, + ); + + return await this.shareEventRepository.save(shareEvent); + } + + private async incrementArticleShareCounter( + articleId: string, + platform: SharePlatform, + ): Promise { + const updateField = this.getShareCounterField(platform); + if (!updateField) return; + + await this.articleRepository + .createQueryBuilder() + .update(Article) + .set({ [updateField]: () => `${updateField} + 1` }) + .where('id = :id', { id: articleId }) + .execute(); + } + + private getShareCounterField(platform: SharePlatform): string | null { + switch (platform) { + case 'facebook': + return 'facebookShares'; + case 'twitter': + return 'twitterShares'; + case 'whatsapp': + return 'whatsappShares'; + case 'telegram': + return 'telegramShares'; + default: + return null; // 'link' shares don't increment counters + } + } + + async getShareStats( + getShareStatsDto: GetShareStatsDto, + ): Promise { + const query = this.articleRepository + .createQueryBuilder('article') + .select([ + 'article.id as "articleId"', + 'article.title as "articleTitle"', + 'article.facebookShares as "facebookShares"', + 'article.twitterShares as "twitterShares"', + 'article.whatsappShares as "whatsappShares"', + 'article.telegramShares as "telegramShares"', + 'article.views as "views"', + 'article.createdAt as "createdAt"', + 'article.updatedAt as "updatedAt"', + ]) + .addSelect( + `(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`, + ) + .addSelect( + `CASE + WHEN article.views > 0 + THEN ROUND( + (article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares)::decimal / article.views * 100, + 2 + ) + ELSE 0 + END as "shareRate"`, + ); + + if (getShareStatsDto.articleId) { + query.where('article.id = :articleId', { + articleId: getShareStatsDto.articleId, + }); + } + + if (getShareStatsDto.startDate) { + query.andWhere('article.createdAt >= :startDate', { + startDate: getShareStatsDto.startDate, + }); + } + + if (getShareStatsDto.endDate) { + query.andWhere('article.createdAt <= :endDate', { + endDate: getShareStatsDto.endDate, + }); + } + + query.orderBy('"totalShares"', 'DESC'); + + const rawResults = await query.getRawMany<{ + articleId: string; + articleTitle: string; + facebookShares: string; + twitterShares: string; + whatsappShares: string; + telegramShares: string; + views: string; + createdAt: string; + updatedAt: string; + totalShares: string; + shareRate: string; + }>(); + + // Get link shares from share_events table + const results: ShareStatsResponse[] = []; + for (const rawResult of rawResults) { + const linkShares = await this.shareEventRepository.count({ + where: { + articleId: rawResult.articleId, + platform: 'link', + }, + }); + + const facebookShares = parseInt(rawResult.facebookShares) || 0; + const twitterShares = parseInt(rawResult.twitterShares) || 0; + const whatsappShares = parseInt(rawResult.whatsappShares) || 0; + const telegramShares = parseInt(rawResult.telegramShares) || 0; + const views = parseInt(rawResult.views) || 0; + const baseTotalShares = + facebookShares + twitterShares + whatsappShares + telegramShares; + const totalShares = baseTotalShares + linkShares; + const shareRate = + views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0; + + results.push({ + articleId: rawResult.articleId, + articleTitle: rawResult.articleTitle, + facebookShares, + twitterShares, + whatsappShares, + telegramShares, + linkShares, + totalShares, + views, + shareRate, + createdAt: new Date(rawResult.createdAt), + updatedAt: new Date(rawResult.updatedAt), + }); + } + + return results; + } + + async getTopSharedArticles( + limit: number = 10, + ): Promise { + const stats = await this.getShareStats({}); + return stats.slice(0, limit); + } + + async getShareTrends( + startDate: Date, + endDate: Date, + interval: 'day' | 'week' | 'month' = 'day', + ): Promise< + Array<{ period: string; platform: SharePlatform; count: number }> + > { + const dateFormat = + interval === 'day' + ? 'YYYY-MM-DD' + : interval === 'week' + ? 'YYYY-WW' + : 'YYYY-MM'; + + const query = this.shareEventRepository + .createQueryBuilder('share_event') + .select([ + `TO_CHAR(share_event.createdAt, '${dateFormat}') as period`, + 'share_event.platform as platform', + 'COUNT(*) as count', + ]) + .where('share_event.createdAt >= :startDate', { startDate }) + .andWhere('share_event.createdAt <= :endDate', { endDate }) + .groupBy('period, platform') + .orderBy('period', 'ASC'); + + const rawResults = await query.getRawMany<{ + period: string; + platform: string; + count: string; + }>(); + + // Convert platform strings to SharePlatform type + return rawResults.map((result) => ({ + period: result.period, + platform: result.platform as SharePlatform, + count: parseInt(result.count) || 0, + })); + } + + async getTotalShareStats(): Promise<{ + totalShares: number; + facebookShares: number; + twitterShares: number; + whatsappShares: number; + telegramShares: number; + linkShares: number; + }> { + interface ArticleStatsRaw { + facebookShares: string; + twitterShares: string; + whatsappShares: string; + telegramShares: string; + } + + const articleStats = (await this.articleRepository + .createQueryBuilder('article') + .select([ + 'SUM(article.facebookShares) as facebookShares', + 'SUM(article.twitterShares) as twitterShares', + 'SUM(article.whatsappShares) as whatsappShares', + 'SUM(article.telegramShares) as telegramShares', + ]) + .getRawOne()) as ArticleStatsRaw; + + const linkShares = await this.shareEventRepository.count({ + where: { platform: 'link' }, + }); + + const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0; + const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0; + const whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0; + const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0; + + const totalShares = + facebookShares + + twitterShares + + whatsappShares + + telegramShares + + linkShares; + + return { + totalShares, + facebookShares, + twitterShares, + whatsappShares, + telegramShares, + linkShares, + }; + } +} diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 0abc5a1..1d20179 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -75,6 +75,30 @@ export class CreateArticleDto { @IsOptional() @IsString() videoCaption?: string; + + @IsOptional() + @IsString() + ogTitle?: string; + + @IsOptional() + @IsString() + ogDescription?: string; + + @IsOptional() + @IsString() + ogImage?: string; + + @IsOptional() + @IsString() + twitterTitle?: string; + + @IsOptional() + @IsString() + twitterDescription?: string; + + @IsOptional() + @IsString() + twitterImage?: string; } export class UpdateArticleDto { @@ -138,6 +162,30 @@ export class UpdateArticleDto { @IsOptional() @IsString() videoCaption?: string; + + @IsOptional() + @IsString() + ogTitle?: string; + + @IsOptional() + @IsString() + ogDescription?: string; + + @IsOptional() + @IsString() + ogImage?: string; + + @IsOptional() + @IsString() + twitterTitle?: string; + + @IsOptional() + @IsString() + twitterDescription?: string; + + @IsOptional() + @IsString() + twitterImage?: string; } export class FindArticlesDto { diff --git a/backend/src/modules/articles.dto.ts.backup b/backend/src/modules/articles.dto.ts.backup new file mode 100644 index 0000000..0abc5a1 --- /dev/null +++ b/backend/src/modules/articles.dto.ts.backup @@ -0,0 +1,356 @@ +import { + IsString, + IsOptional, + IsEnum, + IsArray, + IsUUID, + IsNumber, + IsBoolean, + IsDate, +} from 'class-validator'; +import { + ArticleStatus, + LiveBlogStatus, + ImagePosition, + ImageSize, + VideoPosition, +} from './entities'; + +export class CreateArticleDto { + @IsString() + title: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + excerpt?: string; + + @IsOptional() + @IsString() + slug?: string; + + @IsOptional() + @IsString() + featuredImage?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @IsOptional() + @IsString() + strapiId?: string; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(ImagePosition) + imagePosition?: ImagePosition; + + @IsOptional() + @IsEnum(ImageSize) + imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; +} + +export class UpdateArticleDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + excerpt?: string; + + @IsOptional() + @IsString() + slug?: string; + + @IsOptional() + @IsString() + featuredImage?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @IsOptional() + @IsString() + strapiId?: string; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(ImagePosition) + imagePosition?: ImagePosition; + + @IsOptional() + @IsEnum(ImageSize) + imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; +} + +export class FindArticlesDto { + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + author?: string; + + @IsOptional() + @IsString() + tag?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + page?: number; + + @IsOptional() + limit?: number; +} + +export class CreateLiveBlogDto { + @IsString() + title: string; + + @IsString() + slug: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(LiveBlogStatus) + status?: LiveBlogStatus; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + strapiId?: string; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + featuredImage?: string; + + @IsOptional() + @IsEnum(ImagePosition) + imagePosition?: ImagePosition; + + @IsOptional() + @IsEnum(ImageSize) + imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; +} + +export class UpdateLiveBlogDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + slug?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(LiveBlogStatus) + status?: LiveBlogStatus; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + strapiId?: string; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + featuredImage?: string; + + @IsOptional() + @IsEnum(ImagePosition) + imagePosition?: ImagePosition; + + @IsOptional() + @IsEnum(ImageSize) + imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; +} + +export class CreateLiveBlogUpdateDto { + @IsString() + content: string; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsDate() + scheduledAt?: Date; + + @IsOptional() + @IsString() + strapiId?: string; +} + +export class UpdateLiveBlogUpdateDto { + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsUUID() + authorId?: string; + + @IsOptional() + @IsDate() + scheduledAt?: Date; + + @IsOptional() + @IsString() + strapiId?: string; +} + +export class FindLiveBlogsDto { + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + author?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index 184a4e2..d24cae5 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -224,6 +224,36 @@ export class Article { @Column({ nullable: true }) categoryId: string; + @Column({ type: 'text', nullable: true }) + ogTitle: string; + + @Column({ type: 'text', nullable: true }) + ogDescription: string; + + @Column({ type: 'text', nullable: true }) + ogImage: string; + + @Column({ type: 'text', nullable: true }) + twitterTitle: string; + + @Column({ type: 'text', nullable: true }) + twitterDescription: string; + + @Column({ type: 'text', nullable: true }) + twitterImage: string; + + @Column({ default: 0 }) + facebookShares: number; + + @Column({ default: 0 }) + twitterShares: number; + + @Column({ default: 0 }) + whatsappShares: number; + + @Column({ default: 0 }) + telegramShares: number; + @CreateDateColumn() createdAt: Date; diff --git a/frontend/src/components/features/social-share/CopyLinkButton.tsx b/frontend/src/components/features/social-share/CopyLinkButton.tsx new file mode 100644 index 0000000..c581806 --- /dev/null +++ b/frontend/src/components/features/social-share/CopyLinkButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/features/social-share/ShareButton.tsx b/frontend/src/components/features/social-share/ShareButton.tsx new file mode 100644 index 0000000..861bbc0 --- /dev/null +++ b/frontend/src/components/features/social-share/ShareButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/features/social-share/SocialShareButtons.tsx b/frontend/src/components/features/social-share/SocialShareButtons.tsx new file mode 100644 index 0000000..6f9f7b3 --- /dev/null +++ b/frontend/src/components/features/social-share/SocialShareButtons.tsx @@ -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 ( +
setIsExpanded(true)} + onMouseLeave={() => setIsExpanded(false)} + > +
+ handleShare('link')} + size={getButtonSize()} + variant={getButtonVariant()} + disabled={isTracking} + /> + + {isExpanded && ( +
+ {PLATFORMS.filter(p => p !== 'link').map((platform) => ( + handleShare(platform)} + size={getButtonSize()} + variant={getButtonVariant()} + disabled={isTracking} + /> + ))} +
+ )} +
+
+ ); + } + + return ( +
+ {PLATFORMS.map((platform) => { + if (platform === 'link') { + return ( + + ); + } + + return ( + handleShare(platform)} + size={getButtonSize()} + variant={getButtonVariant()} + disabled={isTracking} + showLabel={showLabels} + /> + ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/features/social-share/index.ts b/frontend/src/components/features/social-share/index.ts new file mode 100644 index 0000000..13fb261 --- /dev/null +++ b/frontend/src/components/features/social-share/index.ts @@ -0,0 +1,4 @@ +export { SocialShareButtons } from './SocialShareButtons'; +export { ShareButton } from './ShareButton'; +export { CopyLinkButton } from './CopyLinkButton'; +export type { SocialShareVariant } from './SocialShareButtons'; \ No newline at end of file diff --git a/frontend/src/components/routes/AdminDashboardComponent.tsx b/frontend/src/components/routes/AdminDashboardComponent.tsx index 5c396a6..e2ba2aa 100644 --- a/frontend/src/components/routes/AdminDashboardComponent.tsx +++ b/frontend/src/components/routes/AdminDashboardComponent.tsx @@ -324,6 +324,11 @@ export function AdminDashboardComponent() { Прегледи: {article.views} + + + Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + + (article.whatsappShares || 0) + (article.telegramShares || 0)} + {article.excerpt && (

@@ -414,9 +419,142 @@ export function AdminDashboardComponent() { - )} + )} - {/* Confirmation Dialog */} + {/* Social Media Analytics - Only show when not viewing archived items */} + {!showArchived && ( +

+ + + Social Media Analytics + Share statistics for articles + + +
+ {/* Total Shares Summary */} +
+ + +
+ {articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0} +
+

Facebook Shares

+
+
+ + +
+ {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0} +
+

Twitter Shares

+
+
+ + +
+ {articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0} +
+

WhatsApp Shares

+
+
+ + +
+ {articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0} +
+

Telegram Shares

+
+
+ + +
+ {articles.reduce((sum, a) => + sum + (a.facebookShares || 0) + (a.twitterShares || 0) + + (a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0} +
+

Total Shares

+
+
+
+ + {/* Top Shared Articles */} +
+

Top Shared Articles

+
+ {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 ( +
+
+
+
+ + {article.title} + +
+
+ Views: {article.views} + + Shares: {totalShares} + + Share Rate: {shareRate}% +
+
+ + + Facebook: {article.facebookShares || 0} + + + + Twitter: {article.twitterShares || 0} + + + + WhatsApp: {article.whatsappShares || 0} + + + + Telegram: {article.telegramShares || 0} + +
+
+
+
+ ); + })} +
+
+
+
+
+
+ )} + + {/* Confirmation Dialog */} {showConfirmDialog && itemToDelete && (
diff --git a/frontend/src/components/routes/ArticleDetailComponent.tsx b/frontend/src/components/routes/ArticleDetailComponent.tsx index 9379229..477821e 100644 --- a/frontend/src/components/routes/ArticleDetailComponent.tsx +++ b/frontend/src/components/routes/ArticleDetailComponent.tsx @@ -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 }) { By {data.author.name} )} -
+
- {data.featuredImage && data.imagePosition !== 'none' && ( + {/* Social Sharing */} +
+ +
+ + {data.featuredImage && data.imagePosition !== 'none' && (
{data.content} -
- + + - {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( + {/* Social Sharing Footer */} +
+
+

Share this article:

+ +
+
+ + {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (

Tags

diff --git a/frontend/src/components/routes/ArticlesComponent.tsx b/frontend/src/components/routes/ArticlesComponent.tsx index e5155dd..6d8286a 100644 --- a/frontend/src/components/routes/ArticlesComponent.tsx +++ b/frontend/src/components/routes/ArticlesComponent.tsx @@ -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() {

Latest news and articles

-
- {data?.data.map((article) => ( - -

- {article.title} -

- {article.excerpt && ( -

- {article.excerpt} -

- )} -
- - {new Date(article.createdAt).toLocaleDateString('mk-MK', { - day: 'numeric', - month: 'short', - year: 'numeric', - })} - - {article.views} views -
- - ))} -
+
+ {data?.data.map((article) => ( +
+ +

+ {article.title} +

+ {article.excerpt && ( +

+ {article.excerpt} +

+ )} + + +
+
+ + {new Date(article.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + {article.views} views +
+ + +
+
+ ))} +
{data?.data.length === 0 && (
diff --git a/frontend/src/components/seo/SocialMetaTags.tsx b/frontend/src/components/seo/SocialMetaTags.tsx new file mode 100644 index 0000000..5f32a2e --- /dev/null +++ b/frontend/src/components/seo/SocialMetaTags.tsx @@ -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, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSocialShare.ts b/frontend/src/hooks/useSocialShare.ts new file mode 100644 index 0000000..510d93f --- /dev/null +++ b/frontend/src/hooks/useSocialShare.ts @@ -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; + copyLink: (url: string) => Promise; +} + +export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn { + const [isSharing, setIsSharing] = useState(false); + const [isCopying, setIsCopying] = useState(false); + const [lastSharedPlatform, setLastSharedPlatform] = useState(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 => { + 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, + }; +} \ No newline at end of file diff --git a/frontend/src/lib/analytics.ts b/frontend/src/lib/analytics.ts new file mode 100644 index 0000000..1ef93b8 --- /dev/null +++ b/frontend/src/lib/analytics.ts @@ -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 => { + 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; + } +}; \ No newline at end of file diff --git a/frontend/src/lib/social-utils.ts b/frontend/src/lib/social-utils.ts new file mode 100644 index 0000000..11672a0 --- /dev/null +++ b/frontend/src/lib/social-utils.ts @@ -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, + 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 => { + 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) + '...'; +}; \ No newline at end of file diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index c504463..d9ed3a8 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -199,6 +199,75 @@ const articleDetailRoute = createRoute({ const { id } = articleDetailRoute.useParams() return }, + 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({ diff --git a/todos.md b/todos.md index 03845a2..61a79ab 100644 --- a/todos.md +++ b/todos.md @@ -1,9 +1,4 @@ -1. role based auth [admin, contributor, user] 3. social media integration [telegram, whatsup, facebook, viber] 4. share with functionality +5. admin dashboard enhancement -lets implement a role based auth -admin can create, edit and delete articles and liveblogs -admin can create contributors -contributors can create, edit and delete articles and liveblogs -user can react(like, dislike) to articles and write comments