Compare commits
7
Commits
auth
...
categories
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46eb41aaa5 | ||
|
|
add12b2fbf | ||
|
|
6d65d5975c | ||
|
|
b8779e5a35 | ||
|
|
000ebd388a | ||
|
|
3374eb1ec0 | ||
|
|
d7b82c0ec9 |
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -91,13 +91,18 @@ export class CommentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('reactions/user')
|
@Get('reactions/user')
|
||||||
@UseGuards(JwtAuthGuard)
|
@Public()
|
||||||
async getUserReaction(
|
async getUserReaction(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Query('articleId') articleId?: string,
|
@Query('articleId') articleId?: string,
|
||||||
@Query('liveBlogId') liveBlogId?: string,
|
@Query('liveBlogId') liveBlogId?: string,
|
||||||
@Query('commentId') commentId?: string,
|
@Query('commentId') commentId?: string,
|
||||||
): Promise<{ type: string | null }> {
|
): Promise<{ type: string | null }> {
|
||||||
|
// If user is not authenticated, return null
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
const reaction = await this.commentService.getUserReaction(
|
const reaction = await this.commentService.getUserReaction(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
articleId,
|
articleId,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
interface CommentEntity {
|
interface CommentEntity {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,7 +31,7 @@ interface CommentEntity {
|
|||||||
|
|
||||||
export class CreateCommentDto {
|
export class CreateCommentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Max(5000)
|
@MaxLength(5000)
|
||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -47,7 +49,7 @@ export class CreateCommentDto {
|
|||||||
|
|
||||||
export class UpdateCommentDto {
|
export class UpdateCommentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Max(5000)
|
@MaxLength(5000)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@@ -118,12 +120,14 @@ export class FindCommentsDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
page?: number = 1;
|
page?: number = 1;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(100)
|
@Max(100)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
limit?: number = 20;
|
limit?: number = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
|
private async findOrCreateCategory(
|
||||||
|
categorySlug: string,
|
||||||
|
): Promise<Category | null> {
|
||||||
|
if (!categorySlug) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return headers;
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -28,7 +28,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: placebo-backend
|
container_name: placebo-backend
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_TYPE: postgres
|
DATABASE_TYPE: postgres
|
||||||
DATABASE_HOST: postgres
|
DATABASE_HOST: postgres
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useCreateComment } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { Textarea } from '../../ui/textarea';
|
||||||
|
import { Card, CardContent } from '../../ui/card';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { mk } from 'date-fns/locale';
|
||||||
|
import type { Comment } from '../../../lib/api';
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: Comment;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
|
const [replyContent, setReplyContent] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
|
||||||
|
const createCommentMutation = useCreateComment();
|
||||||
|
|
||||||
|
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!replyContent.trim() || !isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
await createCommentMutation.mutateAsync({
|
||||||
|
content: replyContent,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
parentCommentId: comment.id,
|
||||||
|
});
|
||||||
|
setReplyContent('');
|
||||||
|
setShowReplyForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post reply:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maximum depth to prevent infinite nesting (optional)
|
||||||
|
const maxDepth = 5;
|
||||||
|
const canReply = depth < maxDepth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{comment.user?.username || 'Анонимен корисник'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comment.user?.role === 'admin' && (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||||
|
Администратор
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
|
||||||
|
|
||||||
|
{/* Reply button and form */}
|
||||||
|
{isAuthenticated && canReply && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{!showReplyForm ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowReplyForm(true)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Одговори
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
|
||||||
|
<form onSubmit={handleSubmitReply}>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Вашиот одговор..."
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
className="min-h-[80px] mb-3"
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowReplyForm(false);
|
||||||
|
setReplyContent('');
|
||||||
|
}}
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
>
|
||||||
|
Откажи
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!replyContent.trim() || isSubmittingReply}
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions (if implemented) */}
|
||||||
|
{comment.reactions && (
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Render replies recursively */}
|
||||||
|
{comment.replies && comment.replies.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<CommentItem
|
||||||
|
key={reply.id}
|
||||||
|
comment={reply}
|
||||||
|
articleId={articleId}
|
||||||
|
liveBlogId={liveBlogId}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@ import { useComments, useCreateComment } from '../../../queries/comments';
|
|||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
import { Textarea } from '../../ui/textarea';
|
import { Textarea } from '../../ui/textarea';
|
||||||
import { Card, CardContent } from '../../ui/card';
|
import { Card, CardContent } from '../../ui/card';
|
||||||
import { format } from 'date-fns';
|
import { CommentItem } from './CommentItem';
|
||||||
import { mk } from 'date-fns/locale';
|
|
||||||
|
|
||||||
interface CommentSectionProps {
|
interface CommentSectionProps {
|
||||||
articleId?: string;
|
articleId?: string;
|
||||||
@@ -99,29 +98,15 @@ export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<Card key={comment.id}>
|
<CommentItem
|
||||||
<CardContent className="pt-6">
|
key={comment.id}
|
||||||
<div className="flex items-start justify-between mb-4">
|
comment={comment}
|
||||||
<div>
|
articleId={articleId}
|
||||||
<div className="font-medium">
|
liveBlogId={liveBlogId}
|
||||||
{comment.user?.username || 'Анонимен корисник'}
|
/>
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{comment.user?.role === 'admin' && (
|
|
||||||
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
|
||||||
Администратор
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="whitespace-pre-wrap">{comment.content}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
>
|
||||||
|
{link.label}
|
||||||
|
</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,12 +83,97 @@ 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>
|
||||||
|
|
||||||
|
{/* 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>
|
</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':
|
||||||
@@ -304,18 +319,23 @@ export function AdminDashboardComponent() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Link
|
<Link
|
||||||
to="/articles/$id"
|
to="/articles/$id"
|
||||||
params={{ id: article.id }}
|
params={{ id: article.id }}
|
||||||
className="font-medium hover:text-primary hover:underline"
|
className="font-medium hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{article.title}
|
{article.title}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge variant="outline" className={getStatusColor(article.status)}>
|
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||||||
{getStatusText(article.status)}
|
{getStatusText(article.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
{article.isHero && (
|
||||||
|
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
|
||||||
|
★ Hero
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
<span>•</span>
|
<span>•</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">
|
||||||
@@ -331,43 +356,51 @@ export function AdminDashboardComponent() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="ghost">
|
<Button asChild size="sm" variant="ghost">
|
||||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||||
Преглед
|
Преглед
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{showArchived ? (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant={article.isHero ? "default" : "outline"}
|
||||||
variant="outline"
|
onClick={() => handleSetHero(article.id, !article.isHero)}
|
||||||
onClick={() => handlePublishClick('article', article.id)}
|
disabled={isProcessing || updateArticleMutation.isPending}
|
||||||
disabled={isProcessing}
|
>
|
||||||
>
|
{article.isHero ? '★ Hero' : 'Set as Hero'}
|
||||||
Објави
|
</Button>
|
||||||
</Button>
|
{showArchived ? (
|
||||||
) : (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => handlePublishClick('article', article.id)}
|
||||||
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
disabled={isProcessing}
|
||||||
disabled={isProcessing}
|
>
|
||||||
>
|
Објави
|
||||||
Архивирај
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
)}
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="destructive"
|
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||||
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
disabled={isProcessing}
|
||||||
disabled={isProcessing}
|
>
|
||||||
>
|
Архивирај
|
||||||
Избриши
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDeleteClick('article', article.id, article.title)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Избриши
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -414,9 +447,142 @@ export function AdminDashboardComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Social Media Analytics - Only show when not viewing archived items */}
|
||||||
|
{!showArchived && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Social Media Analytics</CardTitle>
|
||||||
|
<CardDescription>Share statistics for articles</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Total Shares Summary */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Facebook Shares</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Twitter Shares</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">WhatsApp Shares</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Telegram Shares</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.reduce((sum, a) =>
|
||||||
|
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||||
|
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Shares</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Shared Articles */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Top Shared Articles</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{articles
|
||||||
|
.filter(a => a.status === 'published')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||||
|
(a.whatsappShares || 0) + (a.telegramShares || 0);
|
||||||
|
const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
|
||||||
|
(b.whatsappShares || 0) + (b.telegramShares || 0);
|
||||||
|
return bShares - aShares;
|
||||||
|
})
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((article) => {
|
||||||
|
const totalShares = (article.facebookShares || 0) +
|
||||||
|
(article.twitterShares || 0) +
|
||||||
|
(article.whatsappShares || 0) +
|
||||||
|
(article.telegramShares || 0);
|
||||||
|
const shareRate = article.views > 0
|
||||||
|
? ((totalShares / article.views) * 100).toFixed(2)
|
||||||
|
: '0.00';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to="/articles/$id"
|
||||||
|
params={{ id: article.id }}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Views: {article.views}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Shares: {totalShares}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Share Rate: {shareRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||||
|
Facebook: {article.facebookShares || 0}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-sky-500 rounded-full"></span>
|
||||||
|
Twitter: {article.twitterShares || 0}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
|
WhatsApp: {article.whatsappShares || 0}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-cyan-500 rounded-full"></span>
|
||||||
|
Telegram: {article.telegramShares || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
{showConfirmDialog && itemToDelete && (
|
{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">
|
||||||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -69,9 +70,21 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
<span>By {data.author.name}</span>
|
<span>By {data.author.name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.featuredImage && data.imagePosition !== 'none' && (
|
{/* Social Sharing */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<SocialShareButtons
|
||||||
|
articleId={data.id}
|
||||||
|
title={data.title}
|
||||||
|
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||||
|
excerpt={data.excerpt}
|
||||||
|
image={data.featuredImage}
|
||||||
|
tags={data.tags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.featuredImage && data.imagePosition !== 'none' && (
|
||||||
<div className={`relative mb-4 ${
|
<div className={`relative mb-4 ${
|
||||||
data.imagePosition === 'top'
|
data.imagePosition === 'top'
|
||||||
? 'w-full mb-8'
|
? 'w-full mb-8'
|
||||||
@@ -165,10 +178,26 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
|||||||
>
|
>
|
||||||
{data.content}
|
{data.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
{/* Social Sharing Footer */}
|
||||||
|
<div className="mt-8 pt-8 border-t">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Share this article:</p>
|
||||||
|
<SocialShareButtons
|
||||||
|
articleId={data.id}
|
||||||
|
title={data.title}
|
||||||
|
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||||
|
excerpt={data.excerpt}
|
||||||
|
image={data.featuredImage}
|
||||||
|
tags={data.tags}
|
||||||
|
variant="footer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
|
||||||
<div className="mt-8 pt-8 border-t">
|
<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>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,11 +16,11 @@ export function useLiveBlogStream(
|
|||||||
liveBlogId: string,
|
liveBlogId: string,
|
||||||
options: LiveBlogStreamOptions = {}
|
options: LiveBlogStreamOptions = {}
|
||||||
) {
|
) {
|
||||||
const defaultOptions: LiveBlogStreamOptions = {
|
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
maxReconnectAttempts: 10,
|
maxReconnectAttempts: 10,
|
||||||
};
|
}), []);
|
||||||
|
|
||||||
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
|
||||||
|
import { trackShare } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface UseSocialShareOptions {
|
||||||
|
onSuccess?: (platform: SharePlatform) => void;
|
||||||
|
onError?: (platform: SharePlatform, error: Error) => void;
|
||||||
|
onCopySuccess?: () => void;
|
||||||
|
onCopyError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSocialShareReturn {
|
||||||
|
isSharing: boolean;
|
||||||
|
isCopying: boolean;
|
||||||
|
lastSharedPlatform: SharePlatform | null;
|
||||||
|
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
|
||||||
|
copyLink: (url: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
|
||||||
|
const [isSharing, setIsSharing] = useState(false);
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
|
||||||
|
|
||||||
|
const share = useCallback(async (
|
||||||
|
platform: SharePlatform,
|
||||||
|
data: ShareData & { articleId: string }
|
||||||
|
) => {
|
||||||
|
setIsSharing(true);
|
||||||
|
setLastSharedPlatform(platform);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Track the share event
|
||||||
|
await trackShare({
|
||||||
|
articleId: data.articleId,
|
||||||
|
platform,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
if (options.onSuccess) {
|
||||||
|
options.onSuccess(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open share URL for social platforms (not for 'link')
|
||||||
|
if (platform !== 'link') {
|
||||||
|
const shareUrl = getShareUrl(platform, data);
|
||||||
|
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to share on ${platform}:`, error);
|
||||||
|
|
||||||
|
// Call error callback
|
||||||
|
if (options.onError) {
|
||||||
|
options.onError(platform, error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still open the share URL even if tracking fails (for social platforms)
|
||||||
|
if (platform !== 'link') {
|
||||||
|
const shareUrl = getShareUrl(platform, data);
|
||||||
|
window.open(shareUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const copyLink = useCallback(async (url: string): Promise<boolean> => {
|
||||||
|
setIsCopying(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await copyToClipboard(url);
|
||||||
|
|
||||||
|
if (success && options.onCopySuccess) {
|
||||||
|
options.onCopySuccess();
|
||||||
|
} else if (!success && options.onCopyError) {
|
||||||
|
options.onCopyError(new Error('Failed to copy to clipboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy link:', error);
|
||||||
|
|
||||||
|
if (options.onCopyError) {
|
||||||
|
options.onCopyError(error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsCopying(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSharing,
|
||||||
|
isCopying,
|
||||||
|
lastSharedPlatform,
|
||||||
|
share,
|
||||||
|
copyLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { type SharePlatform } from './social-utils';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
export interface TrackShareParams {
|
||||||
|
articleId: string;
|
||||||
|
platform: SharePlatform;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Failed to track share event:', response.statusText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking share event:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShareStats = async (articleId?: string) => {
|
||||||
|
try {
|
||||||
|
const url = articleId
|
||||||
|
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
|
||||||
|
: `${API_BASE_URL}/api/v1/analytics/shares`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include', // Include cookies for admin auth
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching share stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTopSharedArticles = async (limit: number = 10) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching top shared articles:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTotalShareStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching total share stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
+85
-4
@@ -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) {
|
||||||
@@ -578,6 +595,7 @@ export interface Comment {
|
|||||||
likes: number;
|
likes: number;
|
||||||
dislikes: number;
|
dislikes: number;
|
||||||
};
|
};
|
||||||
|
replies?: Comment[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -622,10 +640,53 @@ export interface CreateReactionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Comment API Functions
|
// Comment API Functions
|
||||||
|
// Interface for backend comment response
|
||||||
|
interface BackendComment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
articleId: string | null;
|
||||||
|
liveBlogId: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
userId: string;
|
||||||
|
likeCount: number;
|
||||||
|
dislikeCount: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
replies?: BackendComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive function to map comment and its replies
|
||||||
|
function mapBackendComment(comment: BackendComment): Comment {
|
||||||
|
const mappedComment: Comment = {
|
||||||
|
...comment,
|
||||||
|
parentCommentId: comment.parentId,
|
||||||
|
// Ensure reactions object exists
|
||||||
|
reactions: {
|
||||||
|
likes: comment.likeCount || 0,
|
||||||
|
dislikes: comment.dislikeCount || 0,
|
||||||
|
},
|
||||||
|
// Recursively map replies if they exist
|
||||||
|
replies: comment.replies?.map(mapBackendComment) || [],
|
||||||
|
};
|
||||||
|
return mappedComment;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
// Map parentCommentId to parentId for backend compatibility
|
||||||
|
const backendParams = { ...params };
|
||||||
|
if (backendParams.parentCommentId) {
|
||||||
|
backendParams.parentId = backendParams.parentCommentId;
|
||||||
|
delete backendParams.parentCommentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(backendParams).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
searchParams.append(key, value.toString());
|
searchParams.append(key, value.toString());
|
||||||
@@ -639,18 +700,38 @@ export async function fetchComments(params: FindCommentsParams = {}): Promise<Co
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch comments');
|
throw new Error('Failed to fetch comments');
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const mappedData = (data as BackendComment[]).map(mapBackendComment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: mappedData,
|
||||||
|
total: mappedData.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
||||||
|
// Map parentCommentId to parentId for backend compatibility
|
||||||
|
const backendDto = {
|
||||||
|
content: dto.content,
|
||||||
|
articleId: dto.articleId,
|
||||||
|
liveBlogId: dto.liveBlogId,
|
||||||
|
parentId: dto.parentCommentId,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await authFetch(`${API_BASE_URL}/comments`, {
|
const response = await authFetch(`${API_BASE_URL}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(dto),
|
body: JSON.stringify(backendDto),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to create comment');
|
throw new Error('Failed to create comment');
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
|
const comment = await response.json() as BackendComment;
|
||||||
|
|
||||||
|
// Map backend response to frontend interface
|
||||||
|
return mapBackendComment(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
||||||
|
|||||||
@@ -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) + '...';
|
||||||
|
};
|
||||||
@@ -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,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()
|
||||||
|
|
||||||
|
|||||||
+207
-126
@@ -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,131 +58,117 @@ 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 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>
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
|
||||||
Placebo<span className="text-primary">.mk</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features grid */}
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<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">Latest Articles</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
||||||
<path d="M12 17h.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Call to action */}
|
|
||||||
<div className="rounded-xl border bg-card p-8 text-center">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
|
|
||||||
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
|
||||||
Dive into our articles or follow live coverage of breaking news as it happens.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Link
|
|
||||||
to="/articles"
|
|
||||||
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">
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
<path d="m12 5 7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/live-blogs"
|
|
||||||
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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
|
|
||||||
>
|
|
||||||
View Live Blogs
|
|
||||||
<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">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar - 1 column */}
|
{/* Pinned Live Blogs Sidebar - 1/3 width */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<PinnedLiveBlogSidebar />
|
<PinnedLiveBlogsSidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Additional sidebar content */}
|
{/* Latest Articles Grid - 4x3 */}
|
||||||
<div className="mt-6 rounded-xl border bg-card p-6">
|
<div className="mb-12">
|
||||||
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
|
<LatestArticlesGrid />
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
</div>
|
||||||
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
|
|
||||||
</p>
|
{/* Brand Introduction */}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="rounded-xl border bg-card p-8 text-center mb-12">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<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">
|
||||||
<span>Live coverage updated in real-time</span>
|
<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" />
|
||||||
</div>
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<line x1="16" x2="8" y1="13" y2="13" />
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
<line x1="16" x2="8" y1="17" y2="17" />
|
||||||
<span>No ads, no sponsors, no BS</span>
|
<line x1="10" x2="8" y1="9" y2="9" />
|
||||||
</div>
|
</svg>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||||
<span>100% independent journalism</span>
|
Placebo<span className="text-primary">.mk</span>
|
||||||
</div>
|
</h1>
|
||||||
</div>
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features grid */}
|
||||||
|
<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="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">
|
||||||
|
<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>
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call to action */}
|
||||||
|
<div className="rounded-xl border bg-card p-8 text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
||||||
|
Dive into our articles or follow live coverage of breaking news as it happens.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Прелистај архива
|
||||||
|
<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="m12 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/live-blogs"
|
||||||
|
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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
|
||||||
|
>
|
||||||
|
View Live Blogs
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,10 +177,28 @@ const indexRoute = createRoute({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in new issue
Block a user