4 Commits
Author SHA1 Message Date
dimitar 46eb41aaa5 category field added
implemented ctegory logic, ditional pages added
2026-02-06 03:35:28 +01:00
dimitar add12b2fbf hero section added
hero article menagment implemented in admin UI
2026-02-06 02:14:10 +01:00
dimitar 6d65d5975c mobile nav implemented
dark mode need rifinements
2026-02-06 01:04:43 +01:00
dimitar b8779e5a35 social share up and running 2026-02-05 03:04:19 +01:00
42 changed files with 3284 additions and 193 deletions

No files matched your search

+2 -1
View File
@@ -23,7 +23,8 @@
"dev:docker": "nest start --watch", "dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env", "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts" "seed:admin": "ts-node scripts/seed-admin.ts",
"seed:categories": "ts-node scripts/seed-categories.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
+65
View File
@@ -0,0 +1,65 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Category } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
try {
const categoryRepository = app.get(getRepositoryToken(Category));
// Define categories with Macedonian names and English slugs
const categories = [
{
name: 'Спорт',
slug: 'sport',
description: 'Спортски вести и анализи',
order: 1,
},
{
name: 'Уметност',
slug: 'art',
description: 'Уметност, култура и забава',
order: 2,
},
{
name: 'Наука',
slug: 'science',
description: 'Научни откритија и технологија',
order: 3,
},
];
console.log('Seeding categories...');
for (const categoryData of categories) {
// Check if category already exists
const existingCategory = await categoryRepository.findOne({
where: { slug: categoryData.slug },
});
if (existingCategory) {
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
} else {
const category = categoryRepository.create(categoryData);
await categoryRepository.save(category);
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
}
}
console.log('\n✅ Category seeding completed!');
console.log('Categories available:');
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
allCategories.forEach((cat: Category) => {
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
});
} catch (error) {
console.error('Error seeding categories:', error);
} finally {
await app.close();
}
}
bootstrap();
+4
View File
@@ -9,6 +9,7 @@ import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module'; import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module'; import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { import {
Article, Article,
Author, Author,
@@ -19,6 +20,7 @@ import {
Comment, Comment,
Reaction, Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
@Module({ @Module({
imports: [ imports: [
@@ -41,6 +43,7 @@ import {
User, User,
Comment, Comment,
Reaction, Reaction,
ShareEvent,
], ],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true', logging: process.env.DATABASE_LOGGING === 'true',
@@ -51,6 +54,7 @@ import {
UserModule, UserModule,
AuthModule, AuthModule,
CommentModule, CommentModule,
AnalyticsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -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<ShareStatsResponse[]> {
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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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 {}
@@ -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<ShareEvent>,
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
) {}
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
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<void> {
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<ShareStatsResponse[]> {
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<ShareStatsResponse[]> {
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,
};
}
}
@@ -40,6 +40,12 @@ export class ArticlesController {
return this.articlesService.findAll(dto); return this.articlesService.findAll(dto);
} }
@Get('hero')
@Public()
findHero() {
return this.articlesService.findHeroArticle();
}
@Get(':id') @Get(':id')
@Public() @Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
+96 -45
View File
@@ -6,7 +6,6 @@ import {
IsUUID, IsUUID,
IsNumber, IsNumber,
IsBoolean, IsBoolean,
IsDate,
} from 'class-validator'; } from 'class-validator';
import { import {
ArticleStatus, ArticleStatus,
@@ -48,6 +47,10 @@ export class CreateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -75,6 +78,30 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; 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 { export class UpdateArticleDto {
@@ -111,6 +138,10 @@ export class UpdateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -138,6 +169,30 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; 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 { export class FindArticlesDto {
@@ -169,6 +224,35 @@ export class FindArticlesDto {
limit?: number; limit?: number;
} }
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;
}
export class CreateLiveBlogDto { export class CreateLiveBlogDto {
@IsString() @IsString()
title: string; title: string;
@@ -288,20 +372,16 @@ export class CreateLiveBlogUpdateDto {
content: string; content: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsString()
isPinned?: boolean; image?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
} }
export class UpdateLiveBlogUpdateDto { export class UpdateLiveBlogUpdateDto {
@@ -310,47 +390,18 @@ export class UpdateLiveBlogUpdateDto {
content?: string; content?: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsString()
isPinned?: boolean; image?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; videoUrl?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
author?: string; authorName?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isPinned?: boolean; isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
} }
+356
View File
@@ -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;
}
+11
View File
@@ -293,4 +293,15 @@ export class ArticlesService {
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
} }
async findHeroArticle(): Promise<Article | null> {
return this.articleRepository.findOne({
where: {
isHero: true,
status: ArticleStatus.PUBLISHED,
},
relations: ['author', 'category'],
order: { createdAt: 'DESC' },
});
}
} }
+33
View File
@@ -224,6 +224,39 @@ export class Article {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; 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;
@Column({ default: false })
isHero: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+3
View File
@@ -1,13 +1,16 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller'; import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module'; import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module'; import { LiveBlogModule } from './live-blog.module';
import { Category } from './entities';
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule), forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule), forwardRef(() => LiveBlogModule),
], ],
+98 -5
View File
@@ -1,6 +1,8 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -11,6 +13,7 @@ import {
ImagePosition, ImagePosition,
ImageSize, ImageSize,
VideoPosition, VideoPosition,
Category,
} from './entities'; } from './entities';
interface StrapiImage { interface StrapiImage {
@@ -45,6 +48,7 @@ interface StrapiArticle {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -64,6 +68,7 @@ interface StrapiLiveBlog {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -91,6 +96,8 @@ export class StrapiService {
private readonly articlesService: ArticlesService, private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService)) @Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService, private readonly liveBlogService: LiveBlogService,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) { ) {
this.strapiUrl = this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337'; this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -99,13 +106,51 @@ export class StrapiService {
} }
private getHeaders() { private getHeaders() {
const headers: Record<string, string> = { return {
'Content-Type': 'application/json', Authorization: `Bearer ${this.strapiApiToken}`,
}; };
if (this.strapiApiToken) {
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
} }
return headers;
private async findOrCreateCategory(
categorySlug: string,
): Promise<Category | null> {
if (!categorySlug) {
return null;
}
// Map CMS category slugs to Macedonian display names
const categoryMap: Record<string, { name: string; description: string }> = {
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
science: { name: 'Наука', description: 'Научни откритија и технологија' },
};
const categoryInfo = categoryMap[categorySlug];
if (!categoryInfo) {
this.logger.warn(`Unknown category slug: ${categorySlug}`);
return null;
}
// Try to find existing category by slug
let category = await this.categoryRepository.findOne({
where: { slug: categorySlug },
});
if (!category) {
// Create new category
category = this.categoryRepository.create({
name: categoryInfo.name,
slug: categorySlug,
description: categoryInfo.description,
order: 0,
});
category = await this.categoryRepository.save(category);
this.logger.log(
`Created new category: ${categorySlug} (${categoryInfo.name})`,
);
}
return category;
} }
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
@@ -181,6 +226,17 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -198,6 +254,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -256,6 +313,17 @@ export class StrapiService {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -270,6 +338,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -333,6 +402,17 @@ export class StrapiService {
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -346,6 +426,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -400,6 +481,17 @@ export class StrapiService {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -412,6 +504,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -63,6 +63,12 @@
"videoCaption": { "videoCaption": {
"type": "string", "type": "string",
"default": "" "default": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
} }
} }
} }
@@ -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';
@@ -0,0 +1,150 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchHeroArticle } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Calendar, User, ArrowRight } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="rounded-xl border bg-card p-8 animate-pulse">
<div className="h-8 bg-muted rounded w-3/4 mb-4"></div>
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
<div className="h-64 bg-muted rounded mb-6"></div>
<div className="h-4 bg-muted rounded w-full mb-2"></div>
<div className="h-4 bg-muted rounded w-5/6 mb-6"></div>
<div className="h-10 bg-muted rounded w-32"></div>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border bg-card p-8 text-center">
<div className="text-red-500 mb-4">Error loading hero article</div>
<Button variant="outline" onClick={() => window.location.reload()}>
Try Again
</Button>
</div>
);
}
if (!article) {
return (
<div className="rounded-xl border bg-card p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h2 className="text-2xl font-bold mb-4">No Hero Article Set</h2>
<p className="text-muted-foreground mb-6">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="text-sm text-muted-foreground">
This space will showcase your most important story.
</div>
</div>
);
}
return (
<div className="rounded-xl border bg-card overflow-hidden hover:shadow-lg transition-shadow duration-300">
{/* Featured Image */}
{article.featuredImage && (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-4 left-6 right-6">
<span className="inline-block px-3 py-1 bg-primary text-primary-foreground text-xs font-semibold rounded-full mb-2">
Featured Story
</span>
</div>
</div>
)}
{/* Content */}
<div className="p-6 md:p-8">
<h2 className="text-2xl md:text-3xl font-bold mb-4 line-clamp-2">
{article.title}
</h2>
{/* Meta Information */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground mb-6">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-1">
<span>{article.views} views</span>
</div>
</div>
{/* Excerpt */}
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3">
{article.excerpt}
</p>
)}
{/* Tags */}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{article.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-xs rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Read More Button */}
<div className="flex items-center justify-between">
<Link to={`/articles/${article.id}`}>
<Button className="gap-2">
Read Full Story
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
{/* Social shares count */}
<div className="text-sm text-muted-foreground">
<span className="font-medium">
{article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares}
</span> shares
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
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 LatestArticlesGrid() {
const { data, isLoading, error } = useQuery({
queryKey: ['latest-articles'],
queryFn: () => api.fetchLatestArticles(12),
})
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="p-6 rounded-xl border bg-card animate-pulse">
<div className="h-48 bg-muted rounded-lg mb-4"></div>
<div className="h-4 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div className="h-3 bg-muted rounded mb-4 w-1/2"></div>
<div className="flex justify-between">
<div className="h-3 bg-muted rounded w-1/4"></div>
<div className="h-3 bg-muted rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="rounded-xl border border-dashed border-red-200 bg-red-50 p-8 text-center">
<div className="text-red-500 mb-2">Грешка при вчитување на статии</div>
<p className="text-sm text-red-600">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="rounded-xl border border-dashed border-muted p-8 text-center">
<div className="text-muted-foreground mb-2">Нема објавени статии</div>
<p className="text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Најнови статии</h2>
<Link
to="/archive"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Види сите
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map((article) => (
<div
key={article.id}
className="group p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
{article.featuredImage ? (
<div className="relative h-48 overflow-hidden rounded-lg mb-4">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
) : (
<div className="h-48 bg-gradient-to-br from-primary/10 to-primary/5 rounded-lg mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
<h3 className="text-lg font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
{article.category.name}
</Link>
)}
</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>
</div>
)
}
@@ -0,0 +1,193 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
export function PinnedLiveBlogsSidebar() {
const { data: liveBlogs, isLoading, error } = useQuery({
queryKey: ['pinned-live-blogs'],
queryFn: fetchPinnedLiveBlogs,
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'live':
return (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
LIVE
</Badge>
);
case 'ended':
return (
<Badge variant="outline" className="border-gray-400 text-gray-400">
ENDED
</Badge>
);
case 'archived':
return (
<Badge variant="outline" className="border-gray-500 text-gray-500">
ARCHIVED
</Badge>
);
default:
return (
<Badge variant="outline">DRAFT</Badge>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
});
};
if (isLoading) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="text-center py-4">
<div className="text-red-500 text-sm mb-2">Error loading live blogs</div>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
if (!liveBlogs || liveBlogs.length === 0) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="text-center py-6">
<div className="text-muted-foreground mb-2">No pinned live blogs</div>
<p className="text-sm text-muted-foreground">
Pin live blogs from the admin panel to feature them here.
</p>
</div>
</div>
);
}
return (
<div className="rounded-xl border bg-card p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<Badge variant="outline" className="text-xs">
{liveBlogs.length} pinned
</Badge>
</div>
{/* Live Blogs List */}
<div className="space-y-4">
{liveBlogs.map((liveBlog) => (
<Link
key={liveBlog.id}
to={`/live-blogs/${liveBlog.id}`}
className="block group"
>
<div className="p-4 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors group-hover:shadow-sm">
{/* Title and Status */}
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium line-clamp-2 group-hover:text-primary transition-colors">
{liveBlog.title}
</h4>
{getStatusBadge(liveBlog.status)}
</div>
{/* Description */}
{liveBlog.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{liveBlog.description}
</p>
)}
{/* Meta Information */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(liveBlog.createdAt)}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{liveBlog.viewCount} views</span>
</div>
{liveBlog.updates && liveBlog.updates.length > 0 && (
<div className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
<span>{liveBlog.updates.length} updates</span>
</div>
)}
{liveBlog.author && (
<div className="text-xs text-muted-foreground">
by {liveBlog.author.name}
</div>
)}
</div>
{/* Featured Image (small) */}
{liveBlog.featuredImage && (
<div className="mt-3">
<div className="relative h-20 rounded-md overflow-hidden">
<img
src={liveBlog.featuredImage}
alt={liveBlog.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</div>
</div>
)}
</div>
</Link>
))}
</div>
{/* View All Link */}
<div className="mt-6 pt-6 border-t">
<Link to="/live-blogs" className="block">
<Button variant="outline" className="w-full justify-center">
View All Live Blogs
</Button>
</Link>
</div>
</div>
);
}
+133 -18
View File
@@ -1,44 +1,74 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { ThemeToggle } from './ThemeToggle';
import { Menu, X } from 'lucide-react';
export function Header() { export function Header() {
const { user, logout, isAuthenticated, hasRole } = useAuth(); const { user, logout, isAuthenticated, hasRole } = useAuth();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
const navLinks = [
{ to: '/', label: 'Почетна' },
{ to: '/sport', label: 'Спорт' },
{ to: '/art', label: 'Уметност' },
{ to: '/science', label: 'Наука' },
{ to: '/archive', label: 'Архива' },
{ to: '/live-blogs', label: 'Live' },
];
const adminLinks = [
{ to: '/admin', label: 'Admin' },
{ to: '/admin/live-blogs/create', label: '+ New Live Blog' },
];
return ( return (
<header className="border-b"> <header className="border-b sticky top-0 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto max-w-6xl px-4 py-4"> <div className="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"> <h1 className="text-2xl md:text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link> <Link to="/" className="hover:underline">Placebo.mk</Link>
</h1> </h1>
<nav className="flex items-center gap-4"> {/* Desktop Navigation */}
<Link to="/" className="text-sm font-medium hover:underline"> <nav className="hidden md:flex items-center gap-4">
Home {navLinks.map((link) => (
</Link> <Link
<Link to="/articles" className="text-sm font-medium hover:underline"> key={link.to}
Articles to={link.to}
</Link> className="text-sm font-medium hover:underline transition-colors"
<Link to="/live-blogs" className="text-sm font-medium hover:underline"> >
Live {link.label}
</Link> </Link>
))}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
{(hasRole('admin') || hasRole('contributor')) && ( {(hasRole('admin') || hasRole('contributor')) && (
<> <>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary"> {adminLinks.map((link) => (
Admin <Link
</Link> key={link.to}
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary"> to={link.to}
+ New Live Blog className="text-sm font-medium hover:underline text-primary transition-colors"
>
{link.label}
</Link> </Link>
))}
</> </>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 ml-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{user?.username} {user?.username}
</span> </span>
@@ -53,13 +83,98 @@ export function Header() {
</div> </div>
</> </>
) : ( ) : (
<Link to="/auth" className="text-sm font-medium hover:underline text-primary"> <Link to="/auth" className="text-sm font-medium hover:underline text-primary transition-colors">
Login / Register Login / Register
</Link> </Link>
)} )}
<ThemeToggle />
</nav> </nav>
{/* Mobile Menu Button */}
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<Button
variant="ghost"
size="icon"
onClick={toggleMobileMenu}
className="h-9 w-9"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div> </div>
</div> </div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t pt-4 animate-in slide-in-from-top-2">
<div className="flex flex-col gap-3">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline py-2 transition-colors"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<div className="border-t pt-3 mt-2">
<p className="text-xs text-muted-foreground mb-2">Admin</p>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline text-primary py-2 block transition-colors"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
</div>
)}
<div className="border-t pt-3 mt-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Logged in as: {user?.username}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
logout();
closeMobileMenu();
}}
className="text-xs"
>
Logout
</Button>
</div>
</div>
</>
) : (
<Link
to="/auth"
className="text-sm font-medium hover:underline text-primary py-2 transition-colors"
onClick={closeMobileMenu}
>
Login / Register
</Link>
)}
</div>
</div>
)}
</div>
</header> </header>
); );
} }
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { getInitialTheme, toggleTheme, watchSystemTheme } from '@/lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(getInitialTheme);
useEffect(() => {
// Watch for system theme changes
const cleanup = watchSystemTheme((newTheme) => {
setTheme(newTheme);
});
return cleanup;
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className="h-9 w-9"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</Button>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs'; import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles'; import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs'; import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -35,6 +35,7 @@ export function AdminDashboardComponent() {
const archiveLiveBlogMutation = useArchiveLiveBlog(); const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle(); const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog(); const publishLiveBlogMutation = usePublishLiveBlog();
const updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
@@ -102,6 +103,20 @@ export function AdminDashboardComponent() {
setItemToDelete(null); setItemToDelete(null);
}; };
const handleSetHero = async (articleId: string, isHero: boolean) => {
setIsProcessing(true);
try {
await updateArticleMutation.mutateAsync({
id: articleId,
dto: { isHero }
});
} catch (error) {
console.error('Failed to update hero status:', error);
} finally {
setIsProcessing(false);
}
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'published': case 'published':
@@ -315,6 +330,11 @@ export function AdminDashboardComponent() {
<Badge variant="outline" className={getStatusColor(article.status)}> <Badge variant="outline" className={getStatusColor(article.status)}>
{getStatusText(article.status)} {getStatusText(article.status)}
</Badge> </Badge>
{article.isHero && (
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
★ Hero
</Badge>
)}
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Слаг: {article.slug}</span> <span>Слаг: {article.slug}</span>
@@ -324,6 +344,11 @@ export function AdminDashboardComponent() {
</span> </span>
<span>•</span> <span>•</span>
<span>Прегледи: {article.views}</span> <span>Прегледи: {article.views}</span>
<span>•</span>
<span>
Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) +
(article.whatsappShares || 0) + (article.telegramShares || 0)}
</span>
</div> </div>
{article.excerpt && ( {article.excerpt && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2"> <p className="mt-2 text-sm text-muted-foreground line-clamp-2">
@@ -340,6 +365,14 @@ export function AdminDashboardComponent() {
Преглед Преглед
</Link> </Link>
</Button> </Button>
<Button
size="sm"
variant={article.isHero ? "default" : "outline"}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set as Hero'}
</Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
@@ -416,6 +449,139 @@ export function AdminDashboardComponent() {
</div> </div>
)} )}
{/* 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 */} {/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && ( {showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArchiveComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} views</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ArtComponent() {
return (
<CategoryPage
categorySlug="art"
categoryName="Уметност"
categoryDescription="Уметност, култура и забава"
/>
)
}
@@ -7,6 +7,7 @@ import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils' import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
import { CommentSection } from '@/components/features/comments/CommentSection' import { CommentSection } from '@/components/features/comments/CommentSection'
import { ReactionButtons } from '@/components/features/comments/ReactionButtons' import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArticleDetailComponent({ id }: { id: string }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -71,6 +72,18 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)} )}
</div> </div>
{/* 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' && ( {data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${ <div className={`relative mb-4 ${
data.imagePosition === 'top' data.imagePosition === 'top'
@@ -168,6 +181,22 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
{/* 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 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3> <h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
@@ -1,72 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticlesComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={article.id}
to={`/articles/${article.id}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
interface CategoryPageProps {
categorySlug: string
categoryName: string
categoryDescription?: string
}
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
queryKey: ['category-articles', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
})
const { data: heroData, isLoading: heroLoading } = useQuery({
queryKey: ['category-hero', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
enabled: !!categorySlug,
})
const heroArticle = heroData?.data?.[0]
const articles = articlesData?.data || []
if (articlesLoading || heroLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Вчитување...</div>
</div>
)
}
if (articlesError) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Category Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold">{categoryName}</h1>
{categoryDescription && (
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
)}
</div>
{/* Hero Section with Pinned Live Blogs Sidebar */}
{heroArticle && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
{/* Hero Article - 2/3 width */}
<div className="lg:col-span-2">
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
<Link to={`/articles/${heroArticle.id}`} className="block">
{heroArticle.featuredImage ? (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={heroArticle.featuredImage}
alt={heroArticle.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
) : (
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
</Link>
<div className="p-6">
<Link to={`/articles/${heroArticle.id}`} className="block">
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
{heroArticle.title}
</h2>
{heroArticle.excerpt && (
<p className="text-muted-foreground mb-4 line-clamp-3">
{heroArticle.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{heroArticle.views} прегледи</span>
</div>
<SocialShareButtons
articleId={heroArticle.id}
title={heroArticle.title}
url={`${window.location.origin}/articles/${heroArticle.id}`}
excerpt={heroArticle.excerpt}
image={heroArticle.featuredImage}
tags={heroArticle.tags}
variant="compact"
/>
</div>
</div>
</div>
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
)}
{/* Articles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{articles.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Нема објавени статии во оваа категорија. Проверете подоцна!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ScienceComponent() {
return (
<CategoryPage
categorySlug="science"
categoryName="Наука"
categoryDescription="Научни откритија и технологија"
/>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function SportComponent() {
return (
<CategoryPage
categorySlug="sport"
categoryName="Спорт"
categoryDescription="Спортски вести и анализи"
/>
)
}
@@ -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;
}
};
+17
View File
@@ -398,6 +398,23 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
return response.json(); return response.json();
} }
export async function fetchHeroArticle(): Promise<Article | null> {
const response = await authFetch(`${API_BASE_URL}/articles/hero`);
if (!response.ok) {
throw new Error('Failed to fetch hero article');
}
const article = await response.json();
return article || null;
}
export async function fetchLatestArticles(limit = 12): Promise<ArticlesResponse> {
const response = await authFetch(`${API_BASE_URL}/articles?status=published&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch latest articles');
}
return response.json();
}
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> { export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`); const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
if (!response.ok) { if (!response.ok) {
+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) + '...';
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Theme utilities for dark/light mode
*/
export type Theme = 'light' | 'dark';
/**
* Get the current theme from localStorage or system preference
*/
export function getInitialTheme(): Theme {
// Check localStorage first
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// Default to light
return 'light';
}
/**
* Apply theme to document
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Save to localStorage
localStorage.setItem('theme', theme);
}
/**
* Initialize theme on page load
*/
export function initializeTheme(): void {
const theme = getInitialTheme();
applyTheme(theme);
}
/**
* Toggle between light and dark themes
*/
export function toggleTheme(): Theme {
const currentTheme = getInitialTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
return newTheme;
}
/**
* Listen for system theme changes
*/
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a preference in localStorage
if (!localStorage.getItem('theme')) {
const theme = e.matches ? 'dark' : 'light';
callback(theme);
applyTheme(theme);
}
};
mediaQuery.addEventListener('change', handleChange);
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange);
}
+4
View File
@@ -4,6 +4,10 @@ import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme'
// Initialize theme before rendering
initializeTheme();
const queryClient = new QueryClient() const queryClient = new QueryClient()
+124 -43
View File
@@ -1,6 +1,6 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { ArticleTicker } from './components/ArticleTicker' import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent' import { ArchiveComponent } from './components/routes/ArchiveComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent' import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
@@ -8,10 +8,15 @@ import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminCompone
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { AuthPage } from './components/routes/AuthPage' import { AuthPage } from './components/routes/AuthPage'
import { SportComponent } from './components/routes/SportComponent'
import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
import { ProtectedRoute } from './components/auth/ProtectedRoute' import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { Header } from './components/layout/Header' import { Header } from './components/layout/Header'
import { HeroArticle } from './components/home/HeroArticle'
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -53,11 +58,26 @@ const indexRoute = createRoute({
<div className="py-8 md:py-12"> <div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> {/* Hero Section with Pinned Live Blogs Sidebar */}
{/* Main content - 3 columns */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<div className="lg:col-span-3 space-y-8"> {/* Hero Article - 2/3 width */}
{/* Hero section */} <div className="lg:col-span-2">
<div className="rounded-xl border bg-card p-8 text-center"> <HeroArticle />
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
{/* Latest Articles Grid - 4x3 */}
<div className="mb-12">
<LatestArticlesGrid />
</div>
{/* Brand Introduction */}
<div className="rounded-xl border bg-card p-8 text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
@@ -76,7 +96,7 @@ const indexRoute = createRoute({
</div> </div>
{/* Features grid */} {/* Features grid */}
<div className="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6 mb-12">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group"> <div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
@@ -130,10 +150,10 @@ const indexRoute = createRoute({
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link
to="/articles" to="/archive"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2" className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
> >
Read Articles Прелистај архива
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<path d="M5 12h14" /> <path d="M5 12h14" />
<path d="m12 5 7 7-7 7" /> <path d="m12 5 7 7-7 7" />
@@ -152,44 +172,33 @@ const indexRoute = createRoute({
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar - 1 column */}
<div className="lg:col-span-1">
<PinnedLiveBlogSidebar />
{/* Additional sidebar content */}
<div className="mt-6 rounded-xl border bg-card p-6">
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
<p className="text-sm text-muted-foreground mb-4">
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
</p>
<div className="text-xs text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Live coverage updated in real-time</span>
</div>
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>No ads, no sponsors, no BS</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span>100% independent journalism</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
), ),
}) })
const articlesRoute = createRoute({ const archiveRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/articles', path: '/archive',
component: ArticlesComponent, component: ArchiveComponent,
})
const sportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sport',
component: SportComponent,
})
const artRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/art',
component: ArtComponent,
})
const scienceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/science',
component: ScienceComponent,
}) })
const articleDetailRoute = createRoute({ const articleDetailRoute = createRoute({
@@ -199,6 +208,75 @@ const articleDetailRoute = createRoute({
const { id } = articleDetailRoute.useParams() const { id } = articleDetailRoute.useParams()
return <ArticleDetailComponent id={id} /> 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({ const liveBlogsRoute = createRoute({
@@ -257,7 +335,10 @@ const adminDashboardRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
articlesRoute, archiveRoute,
sportRoute,
artRoute,
scienceRoute,
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,
+6 -8
View File
@@ -1,9 +1,7 @@
1. role based auth [admin, contributor, user] 5. admin dashboard enhancement
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality
lets implement a role based auth lets add sport, art, science pages. Rename current /articles page to /archive.
admin can create, edit and delete articles and liveblogs create corresponding categories so article will be placed on page according
admin can create contributors to category.
contributors can create, edit and delete articles and liveblogs on home page under a hero section create a card grid component 4x3 where last published article would be placed regardless of category.
user can react(like, dislike) to articles and write comments every page will have simular design as home page with minor design differences.