12 Commits
Author SHA1 Message Date
dimitar 71b1b549c3 admin dashboard fixed
maybe we need another design for this page
2026-02-16 20:42:11 +01:00
dimitar aa79eba06d pading fixed on pages 2026-02-16 19:52:37 +01:00
dimitar 62c68e4bc6 scrollbar on every page 2026-02-16 19:47:24 +01:00
dimitar 2be6216772 date fixed
also прекршени вести Ч)
2026-02-16 19:41:00 +01:00
dimitar ae28f56337 header transparency fix 2026-02-16 19:19:27 +01:00
dimitar 10fe702749 liveblog update fix 2026-02-16 19:15:31 +01:00
dimitar 4c4d741b1f redesign looking good 2026-02-16 18:50:33 +01:00
dimitar 6abae13dbd run 2026-02-16 18:16:06 +01:00
dimitar 46eb41aaa5 category field added
implemented ctegory logic, ditional pages added
2026-02-06 03:35:28 +01:00
dimitar add12b2fbf hero section added
hero article menagment implemented in admin UI
2026-02-06 02:14:10 +01:00
dimitar 6d65d5975c mobile nav implemented
dark mode need rifinements
2026-02-06 01:04:43 +01:00
dimitar b8779e5a35 social share up and running 2026-02-05 03:04:19 +01:00
51 changed files with 3886 additions and 625 deletions

No files matched your search

+2 -1
View File
@@ -23,7 +23,8 @@
"dev:docker": "nest start --watch", "dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env", "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts" "seed:admin": "ts-node scripts/seed-admin.ts",
"seed:categories": "ts-node scripts/seed-categories.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
+65
View File
@@ -0,0 +1,65 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Category } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
try {
const categoryRepository = app.get(getRepositoryToken(Category));
// Define categories with Macedonian names and English slugs
const categories = [
{
name: 'Спорт',
slug: 'sport',
description: 'Спортски вести и анализи',
order: 1,
},
{
name: 'Уметност',
slug: 'art',
description: 'Уметност, култура и забава',
order: 2,
},
{
name: 'Наука',
slug: 'science',
description: 'Научни откритија и технологија',
order: 3,
},
];
console.log('Seeding categories...');
for (const categoryData of categories) {
// Check if category already exists
const existingCategory = await categoryRepository.findOne({
where: { slug: categoryData.slug },
});
if (existingCategory) {
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
} else {
const category = categoryRepository.create(categoryData);
await categoryRepository.save(category);
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
}
}
console.log('\n✅ Category seeding completed!');
console.log('Categories available:');
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
allCategories.forEach((cat: Category) => {
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
});
} catch (error) {
console.error('Error seeding categories:', error);
} finally {
await app.close();
}
}
bootstrap();
+4
View File
@@ -9,6 +9,7 @@ import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module'; import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module'; import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { import {
Article, Article,
Author, Author,
@@ -19,6 +20,7 @@ import {
Comment, Comment,
Reaction, Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
@Module({ @Module({
imports: [ imports: [
@@ -41,6 +43,7 @@ import {
User, User,
Comment, Comment,
Reaction, Reaction,
ShareEvent,
], ],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true', logging: process.env.DATABASE_LOGGING === 'true',
@@ -51,6 +54,7 @@ import {
UserModule, UserModule,
AuthModule, AuthModule,
CommentModule, CommentModule,
AnalyticsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -0,0 +1,76 @@
import {
Controller,
Post,
Body,
Get,
Query,
UsePipes,
ValidationPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
import { Public } from '../auth/public.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Public()
@Post('share')
@HttpCode(HttpStatus.CREATED)
@UsePipes(new ValidationPipe({ transform: true }))
async trackShare(@Body() trackShareDto: TrackShareDto) {
return await this.analyticsService.trackShare(trackShareDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares')
@UsePipes(new ValidationPipe({ transform: true }))
async getShareStats(
@Query() getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
return await this.analyticsService.getShareStats(getShareStatsDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/top')
async getTopSharedArticles(@Query('limit') limit: string) {
const limitNum = limit ? parseInt(limit) : 10;
return await this.analyticsService.getTopSharedArticles(limitNum);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/trends')
async getShareTrends(
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('interval') interval: 'day' | 'week' | 'month',
) {
return await this.analyticsService.getShareTrends(
new Date(startDate),
new Date(endDate),
interval || 'day',
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/total')
async getTotalShareStats() {
return await this.analyticsService.getTotalShareStats();
}
}
@@ -0,0 +1,47 @@
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import type { SharePlatform } from './analytics.entity';
export class TrackShareDto {
@IsUUID()
articleId: string;
@IsEnum(['facebook', 'twitter', 'whatsapp', 'telegram', 'link'])
platform: SharePlatform;
@IsOptional()
@IsString()
userAgent?: string;
@IsOptional()
@IsString()
ipAddress?: string;
}
export class GetShareStatsDto {
@IsOptional()
@IsUUID()
articleId?: string;
@IsOptional()
@IsString()
startDate?: string;
@IsOptional()
@IsString()
endDate?: string;
}
export class ShareStatsResponse {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Article } from '../entities';
export type SharePlatform =
| 'facebook'
| 'twitter'
| 'whatsapp'
| 'telegram'
| 'link';
@Entity('share_events')
export class ShareEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
articleId: string;
@Column({ type: 'text' })
platform: SharePlatform;
@Column({ nullable: true })
userId: string;
@Column({ nullable: true })
userAgent: string;
@Column({ nullable: true })
ipAddress: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
}
export interface ShareStats {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ShareEvent } from './analytics.entity';
import { Article } from '../entities';
@Module({
imports: [TypeOrmModule.forFeature([ShareEvent, Article])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -0,0 +1,266 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShareEvent, SharePlatform } from './analytics.entity';
import { Article } from '../entities';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
@InjectRepository(ShareEvent)
private readonly shareEventRepository: Repository<ShareEvent>,
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
) {}
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
const shareEvent = this.shareEventRepository.create(trackShareDto);
// Also update the article's share counters
await this.incrementArticleShareCounter(
trackShareDto.articleId,
trackShareDto.platform,
);
return await this.shareEventRepository.save(shareEvent);
}
private async incrementArticleShareCounter(
articleId: string,
platform: SharePlatform,
): Promise<void> {
const updateField = this.getShareCounterField(platform);
if (!updateField) return;
await this.articleRepository
.createQueryBuilder()
.update(Article)
.set({ [updateField]: () => `${updateField} + 1` })
.where('id = :id', { id: articleId })
.execute();
}
private getShareCounterField(platform: SharePlatform): string | null {
switch (platform) {
case 'facebook':
return 'facebookShares';
case 'twitter':
return 'twitterShares';
case 'whatsapp':
return 'whatsappShares';
case 'telegram':
return 'telegramShares';
default:
return null; // 'link' shares don't increment counters
}
}
async getShareStats(
getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
const query = this.articleRepository
.createQueryBuilder('article')
.select([
'article.id as "articleId"',
'article.title as "articleTitle"',
'article.facebookShares as "facebookShares"',
'article.twitterShares as "twitterShares"',
'article.whatsappShares as "whatsappShares"',
'article.telegramShares as "telegramShares"',
'article.views as "views"',
'article.createdAt as "createdAt"',
'article.updatedAt as "updatedAt"',
])
.addSelect(
`(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`,
)
.addSelect(
`CASE
WHEN article.views > 0
THEN ROUND(
(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares)::decimal / article.views * 100,
2
)
ELSE 0
END as "shareRate"`,
);
if (getShareStatsDto.articleId) {
query.where('article.id = :articleId', {
articleId: getShareStatsDto.articleId,
});
}
if (getShareStatsDto.startDate) {
query.andWhere('article.createdAt >= :startDate', {
startDate: getShareStatsDto.startDate,
});
}
if (getShareStatsDto.endDate) {
query.andWhere('article.createdAt <= :endDate', {
endDate: getShareStatsDto.endDate,
});
}
query.orderBy('"totalShares"', 'DESC');
const rawResults = await query.getRawMany<{
articleId: string;
articleTitle: string;
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
views: string;
createdAt: string;
updatedAt: string;
totalShares: string;
shareRate: string;
}>();
// Get link shares from share_events table
const results: ShareStatsResponse[] = [];
for (const rawResult of rawResults) {
const linkShares = await this.shareEventRepository.count({
where: {
articleId: rawResult.articleId,
platform: 'link',
},
});
const facebookShares = parseInt(rawResult.facebookShares) || 0;
const twitterShares = parseInt(rawResult.twitterShares) || 0;
const whatsappShares = parseInt(rawResult.whatsappShares) || 0;
const telegramShares = parseInt(rawResult.telegramShares) || 0;
const views = parseInt(rawResult.views) || 0;
const baseTotalShares =
facebookShares + twitterShares + whatsappShares + telegramShares;
const totalShares = baseTotalShares + linkShares;
const shareRate =
views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0;
results.push({
articleId: rawResult.articleId,
articleTitle: rawResult.articleTitle,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
totalShares,
views,
shareRate,
createdAt: new Date(rawResult.createdAt),
updatedAt: new Date(rawResult.updatedAt),
});
}
return results;
}
async getTopSharedArticles(
limit: number = 10,
): Promise<ShareStatsResponse[]> {
const stats = await this.getShareStats({});
return stats.slice(0, limit);
}
async getShareTrends(
startDate: Date,
endDate: Date,
interval: 'day' | 'week' | 'month' = 'day',
): Promise<
Array<{ period: string; platform: SharePlatform; count: number }>
> {
const dateFormat =
interval === 'day'
? 'YYYY-MM-DD'
: interval === 'week'
? 'YYYY-WW'
: 'YYYY-MM';
const query = this.shareEventRepository
.createQueryBuilder('share_event')
.select([
`TO_CHAR(share_event.createdAt, '${dateFormat}') as period`,
'share_event.platform as platform',
'COUNT(*) as count',
])
.where('share_event.createdAt >= :startDate', { startDate })
.andWhere('share_event.createdAt <= :endDate', { endDate })
.groupBy('period, platform')
.orderBy('period', 'ASC');
const rawResults = await query.getRawMany<{
period: string;
platform: string;
count: string;
}>();
// Convert platform strings to SharePlatform type
return rawResults.map((result) => ({
period: result.period,
platform: result.platform as SharePlatform,
count: parseInt(result.count) || 0,
}));
}
async getTotalShareStats(): Promise<{
totalShares: number;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
}> {
interface ArticleStatsRaw {
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
}
const articleStats = (await this.articleRepository
.createQueryBuilder('article')
.select([
'SUM(article.facebookShares) as facebookShares',
'SUM(article.twitterShares) as twitterShares',
'SUM(article.whatsappShares) as whatsappShares',
'SUM(article.telegramShares) as telegramShares',
])
.getRawOne()) as ArticleStatsRaw;
const linkShares = await this.shareEventRepository.count({
where: { platform: 'link' },
});
const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0;
const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0;
const whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0;
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
const totalShares =
facebookShares +
twitterShares +
whatsappShares +
telegramShares +
linkShares;
return {
totalShares,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
};
}
}
@@ -40,6 +40,12 @@ export class ArticlesController {
return this.articlesService.findAll(dto); return this.articlesService.findAll(dto);
} }
@Get('hero')
@Public()
findHero() {
return this.articlesService.findHeroArticle();
}
@Get(':id') @Get(':id')
@Public() @Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
+103 -35
View File
@@ -6,7 +6,7 @@ import {
IsUUID, IsUUID,
IsNumber, IsNumber,
IsBoolean, IsBoolean,
IsDate, IsDateString,
} from 'class-validator'; } from 'class-validator';
import { import {
ArticleStatus, ArticleStatus,
@@ -48,6 +48,10 @@ export class CreateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -75,6 +79,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 +139,10 @@ export class UpdateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -138,6 +170,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 +225,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;
@@ -292,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsDateString()
scheduledAt?: Date; scheduledAt?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; image?: string;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
} }
export class UpdateLiveBlogUpdateDto { export class UpdateLiveBlogUpdateDto {
@@ -314,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsString()
scheduledAt?: Date; image?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; videoUrl?: string;
}
export class FindLiveBlogsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
category?: string; authorName?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
} }
+356
View File
@@ -0,0 +1,356 @@
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
title: string;
@IsString()
content: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateArticleDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class FindArticlesDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto {
@IsString()
title: string;
@IsString()
slug: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class UpdateLiveBlogDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
}
export class CreateLiveBlogUpdateDto {
@IsString()
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class UpdateLiveBlogUpdateDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
+11
View File
@@ -293,4 +293,15 @@ export class ArticlesService {
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
} }
async findHeroArticle(): Promise<Article | null> {
return this.articleRepository.findOne({
where: {
isHero: true,
status: ArticleStatus.PUBLISHED,
},
relations: ['author', 'category'],
order: { createdAt: 'DESC' },
});
}
} }
+33
View File
@@ -224,6 +224,39 @@ export class Article {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; categoryId: string;
@Column({ type: 'text', nullable: true })
ogTitle: string;
@Column({ type: 'text', nullable: true })
ogDescription: string;
@Column({ type: 'text', nullable: true })
ogImage: string;
@Column({ type: 'text', nullable: true })
twitterTitle: string;
@Column({ type: 'text', nullable: true })
twitterDescription: string;
@Column({ type: 'text', nullable: true })
twitterImage: string;
@Column({ default: 0 })
facebookShares: number;
@Column({ default: 0 })
twitterShares: number;
@Column({ default: 0 })
whatsappShares: number;
@Column({ default: 0 })
telegramShares: number;
@Column({ default: false })
isHero: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+3
View File
@@ -1,13 +1,16 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller'; import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module'; import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module'; import { LiveBlogModule } from './live-blog.module';
import { Category } from './entities';
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule), forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule), forwardRef(() => LiveBlogModule),
], ],
+98 -5
View File
@@ -1,6 +1,8 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -11,6 +13,7 @@ import {
ImagePosition, ImagePosition,
ImageSize, ImageSize,
VideoPosition, VideoPosition,
Category,
} from './entities'; } from './entities';
interface StrapiImage { interface StrapiImage {
@@ -45,6 +48,7 @@ interface StrapiArticle {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -64,6 +68,7 @@ interface StrapiLiveBlog {
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -91,6 +96,8 @@ export class StrapiService {
private readonly articlesService: ArticlesService, private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService)) @Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService, private readonly liveBlogService: LiveBlogService,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) { ) {
this.strapiUrl = this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337'; this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -99,13 +106,51 @@ export class StrapiService {
} }
private getHeaders() { private getHeaders() {
const headers: Record<string, string> = { return {
'Content-Type': 'application/json', Authorization: `Bearer ${this.strapiApiToken}`,
}; };
if (this.strapiApiToken) {
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
} }
return headers;
private async findOrCreateCategory(
categorySlug: string,
): Promise<Category | null> {
if (!categorySlug) {
return null;
}
// Map CMS category slugs to Macedonian display names
const categoryMap: Record<string, { name: string; description: string }> = {
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
science: { name: 'Наука', description: 'Научни откритија и технологија' },
};
const categoryInfo = categoryMap[categorySlug];
if (!categoryInfo) {
this.logger.warn(`Unknown category slug: ${categorySlug}`);
return null;
}
// Try to find existing category by slug
let category = await this.categoryRepository.findOne({
where: { slug: categorySlug },
});
if (!category) {
// Create new category
category = this.categoryRepository.create({
name: categoryInfo.name,
slug: categorySlug,
description: categoryInfo.description,
order: 0,
});
category = await this.categoryRepository.save(category);
this.logger.log(
`Created new category: ${categorySlug} (${categoryInfo.name})`,
);
}
return category;
} }
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
@@ -181,6 +226,17 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -198,6 +254,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -256,6 +313,17 @@ export class StrapiService {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -270,6 +338,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -333,6 +402,17 @@ export class StrapiService {
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -346,6 +426,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -400,6 +481,17 @@ export class StrapiService {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -412,6 +504,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -63,6 +63,12 @@
"videoCaption": { "videoCaption": {
"type": "string", "type": "string",
"default": "" "default": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
} }
} }
} }
+10 -11
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import { Zap } from 'lucide-react'
export function ArticleTicker() { export function ArticleTicker() {
const { data } = useQuery({ const { data } = useQuery({
@@ -13,29 +14,28 @@ export function ArticleTicker() {
if (articles.length === 0) return null if (articles.length === 0) return null
return ( return (
<div className="overflow-hidden bg-muted/50 border-y"> <div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
<div className="container mx-auto max-w-6xl px-4"> <div className="py-2 flex items-center">
<div className="py-2 flex items-center gap-4"> <div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
<span className="text-sm font-semibold text-primary whitespace-nowrap"> <Zap className="w-4 h-4" />
Latest: Топ вести
</span> </div>
<div className="overflow-hidden flex-1 relative"> <div className="overflow-hidden flex-1">
<div className="flex animate-marquee whitespace-nowrap"> <div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`${article.id}-${index}`} key={`${article.id}-${index}`}
to={`/articles/${article.id}`} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
</Link> </Link>
))} ))}
{/* Duplicate for seamless scrolling */}
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`dup-${article.id}-${index}`} key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}`} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
</Link> </Link>
@@ -44,6 +44,5 @@ export function ArticleTicker() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs'; import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react'; import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
@@ -9,7 +8,7 @@ interface LiveBlogTickerProps {
className?: string; className?: string;
maxItems?: number; maxItems?: number;
autoScroll?: boolean; autoScroll?: boolean;
scrollSpeed?: number; // pixels per second scrollSpeed?: number;
} }
export function LiveBlogTicker({ export function LiveBlogTicker({
@@ -25,7 +24,6 @@ export function LiveBlogTicker({
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number | undefined>(undefined); const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0); const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => { useEffect(() => {
@@ -35,7 +33,6 @@ export function LiveBlogTicker({
} }
}, [activeBlogs]); }, [activeBlogs]);
// Auto-scroll animation
useEffect(() => { useEffect(() => {
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) { if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
if (animationRef.current) { if (animationRef.current) {
@@ -53,7 +50,6 @@ export function LiveBlogTicker({
setScrollPosition(prev => { setScrollPosition(prev => {
let newPos = prev + (scrollSpeed * delta) / 1000; let newPos = prev + (scrollSpeed * delta) / 1000;
// Reset when scrolled past content
if (newPos > totalWidth) { if (newPos > totalWidth) {
newPos = -(tickerRef.current?.offsetWidth || 0); newPos = -(tickerRef.current?.offsetWidth || 0);
} }
@@ -75,7 +71,7 @@ export function LiveBlogTicker({
if (isLoading) { if (isLoading) {
return ( return (
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}> <div className={`border-brutal-sm bg-card p-3 ${className}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div> <div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
@@ -87,7 +83,7 @@ export function LiveBlogTicker({
} }
if (!activeBlogs || activeBlogs.length === 0) { if (!activeBlogs || activeBlogs.length === 0) {
return null; // Don't show ticker if no active blogs return null;
} }
const displayBlogs = activeBlogs.slice(0, maxItems); const displayBlogs = activeBlogs.slice(0, maxItems);
@@ -97,56 +93,48 @@ export function LiveBlogTicker({
}; };
const handleBlogClick = () => { const handleBlogClick = () => {
// Reset scroll position when user interacts
setScrollPosition(0); setScrollPosition(0);
}; };
return ( return (
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}> <div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
{/* Ticker header */} <div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <Radio className="w-4 h-4 text-accent animate-pulse" />
<Radio className="w-4 h-4 text-primary" /> <span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
<span className="font-medium text-sm">Live Now</span> <span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
<Badge variant="secondary" className="text-xs"> {activeBlogs.length}
{activeBlogs.length} active </span>
</Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{autoScroll && ( {autoScroll && (
<Button <Button
variant="ghost" variant="brutal"
size="sm" size="sm"
onClick={handlePauseToggle} onClick={handlePauseToggle}
className="h-7 px-2" className="h-7 px-2 text-xs"
> >
{isPaused ? ( {isPaused ? (
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
) : ( ) : (
<Pause className="w-3 h-3" /> <Pause className="w-3 h-3" />
)} )}
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
</Button> </Button>
)} )}
<Link to="/live-blogs"> <Link to="/live-blogs">
<Button variant="ghost" size="sm" className="h-7 px-2"> <Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
<span className="text-xs">View All</span> Сите
<ChevronRight className="w-3 h-3 ml-1" /> <ChevronRight className="w-3 h-3 ml-1" />
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Ticker content */}
<div <div
ref={tickerRef} ref={tickerRef}
className="relative h-12 overflow-hidden" className="relative h-12 overflow-hidden"
style={{
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
}}
> >
<div <div
ref={contentRef} ref={contentRef}
@@ -156,76 +144,63 @@ export function LiveBlogTicker({
transition: isPaused ? 'transform 0.3s ease' : 'none' transition: isPaused ? 'transform 0.3s ease' : 'none'
}} }}
> >
{displayBlogs.map((blog, index) => ( {displayBlogs.map((blog) => (
<React.Fragment key={blog.id}> <React.Fragment key={blog.id}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{/* Separator (except after last item) */}
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
{/* Duplicate content for seamless looping */} {displayBlogs.map((blog) => (
{displayBlogs.map((blog, index) => (
<React.Fragment key={`${blog.id}-dup`}> <React.Fragment key={`${blog.id}-dup`}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
</div> </div>
{/* Progress indicator */}
{autoScroll && totalWidth > 0 && ( {autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="h-1 bg-muted rounded-full overflow-hidden"> <div className="h-1 bg-foreground/10">
<div <div
className="h-full bg-primary transition-all duration-300" className="h-full bg-accent transition-all duration-300"
style={{ style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%` width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}} }}
@@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
<div <div
ref={updatesContainerRef} ref={updatesContainerRef}
className="max-h-[600px] overflow-y-auto px-6 pb-6" className="px-6 pb-6"
onScroll={handleScroll} onScroll={handleScroll}
> >
{updates.length === 0 && !updatesLoading ? ( {updates.length === 0 && !updatesLoading ? (
@@ -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,141 @@
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, Eye } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="border-brutal bg-card p-0 animate-pulse">
<div className="h-80 bg-muted"></div>
<div className="p-8">
<div className="h-10 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-20 bg-muted rounded mb-6"></div>
<div className="h-12 bg-muted rounded w-40"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
<Button variant="brutal" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (!article) {
return (
<div className="border-brutal bg-card p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
<span className="font-display text-4xl">?</span>
</div>
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
<p className="font-body text-muted-foreground mb-4">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
This space will showcase your most important story
</div>
</div>
);
}
return (
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
{article.featuredImage && (
<div className="relative overflow-hidden">
<div className="absolute top-0 left-0 z-10">
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
Прекршени Вести
</span>
</div>
<div className="relative h-72 md:h-96 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
</div>
</div>
)}
<div className="p-6 md:p-8">
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h2>
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
<span>{article.views} views</span>
</div>
</div>
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
{article.excerpt}
</p>
)}
{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 font-body uppercase tracking-wider border-2 border-foreground bg-background"
>
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
<Link to={`/articles/${article.id}`}>
<Button variant="brutalAccent" className="gap-2">
Read Full Story
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
<span className="font-bold text-foreground">
{(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
</span> shares
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,136 @@
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 { ArrowRight } from 'lucide-react'
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-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-brutal-sm bg-card p-4 animate-pulse">
<div className="h-40 bg-muted mb-4"></div>
<div className="h-6 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 w-1/2"></div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="border-brutal bg-destructive/10 p-8 text-center">
<div className="text-destructive text-2xl font-display mb-2">ГРЕШКА</div>
<p className="font-body text-sm text-destructive">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="font-display text-2xl mb-2">НЕМА СТАТИИ</div>
<p className="font-body text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between border-b-4 border-foreground pb-4">
<h2 className="text-3xl md:text-4xl font-display">Најнови</h2>
<Link
to="/archive"
className="font-body text-sm uppercase tracking-wider border-2 border-foreground px-4 py-2 hover:bg-accent hover:border-accent transition-all duration-150 flex items-center gap-2"
>
Сите
<ArrowRight className="w-4 h-4" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article, index) => (
<article
key={article.id}
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
>
<Link
to={`/articles/${article.id}`}
className="block"
>
{article.featuredImage ? (
<div className="relative h-40 overflow-hidden border-b-2 border-foreground">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors duration-300" />
</div>
) : (
<div className="h-40 bg-secondary border-b-2 border-foreground flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)', backgroundSize: '10px 10px' }}></div>
<span className="font-display text-4xl text-foreground/30">N</span>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-display leading-tight mb-2 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-xs font-body line-clamp-2 mb-3">
{article.excerpt}
</p>
)}
</div>
</Link>
<div className="px-4 pb-4 border-t-2 border-foreground/10 pt-3 mt-auto">
<div className="flex items-center justify-between font-body text-xs uppercase tracking-wider text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
})}
</span>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
>
{article.category.name}
</Link>
)}
</div>
<div className="mt-3">
<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>
</article>
))}
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api';
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 (
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
LIVE
</span>
);
case 'ended':
return (
<span className="px-2 py-0.5 border-2 border-foreground/40 text-foreground/40 text-xs font-body font-bold uppercase">
ENDED
</span>
);
case 'archived':
return (
<span className="px-2 py-0.5 border-2 border-foreground/30 text-foreground/30 text-xs font-body font-bold uppercase">
ARCHIVED
</span>
);
default:
return (
<span className="px-2 py-0.5 border-2 border-foreground text-foreground text-xs font-body font-bold uppercase">
DRAFT
</span>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
});
};
if (isLoading) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</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="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-4">
<div className="text-destructive font-body text-sm mb-2">ERROR</div>
<Button variant="brutal" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
if (!liveBlogs || liveBlogs.length === 0) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-6 border-2 border-dashed border-foreground/20 p-4">
<div className="font-body text-muted-foreground mb-2">No pinned live blogs</div>
<p className="font-body text-xs text-muted-foreground">
Pin live blogs from the admin panel.
</p>
</div>
</div>
);
}
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-accent" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
{liveBlogs.length}
</span>
</div>
<div className="space-y-4">
{liveBlogs.map((liveBlog) => (
<Link
key={liveBlog.id}
to="/live-blogs/$slug"
params={{ slug: liveBlog.slug }}
className="block group"
>
<div className="p-4 border-2 border-foreground/10 hover:border-foreground hover:shadow-brutal-sm transition-all duration-150 group-hover:-translate-y-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-body text-sm font-bold leading-tight line-clamp-2 group-hover:text-accent transition-colors">
{liveBlog.title}
</h4>
{getStatusBadge(liveBlog.status)}
</div>
{liveBlog.description && (
<p className="text-xs font-body text-muted-foreground mb-3 line-clamp-2">
{liveBlog.description}
</p>
)}
<div className="flex flex-wrap items-center gap-3 text-xs font-body 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}</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}</span>
</div>
)}
{liveBlog.author && (
<div className="text-xs text-muted-foreground">
{liveBlog.author.name}
</div>
)}
</div>
{liveBlog.featuredImage && (
<div className="mt-3">
<div className="relative h-20 border-2 border-foreground/10 overflow-hidden">
<img
src={liveBlog.featuredImage}
alt={liveBlog.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
</div>
</div>
)}
</div>
</Link>
))}
</div>
<div className="mt-6 pt-4 border-t-2 border-foreground/10">
<Link to="/live-blogs" className="block">
<Button variant="brutal" className="w-full justify-center">
Сите Live
</Button>
</Link>
</div>
</div>
);
}
+172 -26
View File
@@ -1,65 +1,211 @@
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, Zap } from 'lucide-react';
const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември'];
const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела'];
const formatDateMk = () => {
const d = new Date();
return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`;
};
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' },
];
return ( return (
<header className="border-b"> <header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]">
<div className="border-b-2 border-foreground/20">
<div className="container mx-auto max-w-6xl px-4">
<div className="flex items-center justify-between py-2">
<div className="hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-muted-foreground">
<Zap className="w-3 h-3" />
<span>Сатирични вести од Македонија</span>
</div>
<div className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
{formatDateMk()}
</div>
</div>
</div>
</div>
<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"> <Link to="/" className="group">
<Link to="/" className="hover:underline">Placebo.mk</Link> <h1 className="text-4xl md:text-5xl font-display tracking-tight">
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-150">c</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-200">e</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-250">b</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-300">o</span>
<span className="text-accent inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-350">.</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1 delay-400">m</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-500">k</span>
</h1> </h1>
</Link>
<nav className="flex items-center gap-4"> <div className="hidden md:flex items-center gap-1">
<Link to="/" className="text-sm font-medium hover:underline"> {navLinks.map((link, index) => (
Home <Link
</Link> key={link.to}
<Link to="/articles" className="text-sm font-medium hover:underline"> to={link.to}
Articles className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-transparent hover:border-foreground hover:bg-accent hover:text-foreground transition-all duration-150"
</Link> style={{ animationDelay: `${index * 50}ms` }}
<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="px-3 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent transition-all duration-150"
>
{link.label}
</Link> </Link>
))}
</> </>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
<span className="text-sm text-muted-foreground"> <span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username} {user?.username}
</span> </span>
<Button <Button
variant="outline" variant="brutal"
size="sm" size="sm"
onClick={logout} onClick={logout}
className="text-xs"
> >
Logout OUT
</Button> </Button>
</div> </div>
</> </>
) : ( ) : (
<Link to="/auth" className="text-sm font-medium hover:underline text-primary"> <Link to="/auth" className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 ml-2">
Login / Register Login
</Link> </Link>
)} )}
</nav>
<div className="ml-4 pl-4 border-l-2 border-foreground/20">
<ThemeToggle />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<Button
variant="brutal"
size="icon"
onClick={toggleMobileMenu}
className="h-10 w-10"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{isMobileMenuOpen && (
<div className="md:hidden mt-4 pt-4 border-t-4 border-foreground animate-scale-in">
<div className="flex flex-col gap-2">
{navLinks.map((link, index) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground hover:bg-accent hover:border-accent transition-all duration-150"
style={{ animationDelay: `${index * 50}ms` }}
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<div className="pt-3 mt-2 border-t-4 border-foreground">
<p className="font-body text-xs uppercase tracking-wider text-muted-foreground mb-2 pl-2">Admin</p>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent hover:bg-foreground hover:text-accent transition-all duration-150 block"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
</div>
)}
<div className="pt-3 mt-2 border-t-4 border-foreground">
<div className="flex items-center justify-between p-2">
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username}
</span>
<Button
variant="brutal"
size="sm"
onClick={() => {
logout();
closeMobileMenu();
}}
>
OUT
</Button>
</div>
</div>
</>
) : (
<Link
to="/auth"
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 mt-2"
onClick={closeMobileMenu}
>
Login
</Link>
)}
</div>
</div>
)}
</div>
</header> </header>
); );
} }
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { getInitialTheme, toggleTheme, watchSystemTheme } from '@/lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(getInitialTheme);
useEffect(() => {
// Watch for system theme changes
const cleanup = watchSystemTheme((newTheme) => {
setTheme(newTheme);
});
return cleanup;
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className="h-9 w-9"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</Button>
);
}
@@ -1,16 +1,13 @@
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';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
// State for confirmation dialog and filters
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete'); const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{ const [itemToDelete, setItemToDelete] = useState<{
@@ -35,14 +32,11 @@ 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 || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => { const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title }); setItemToDelete({ type, id, title });
setDialogType('delete'); setDialogType('delete');
@@ -81,7 +75,7 @@ export function AdminDashboardComponent() {
} else { } else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
} }
} else { // archive } else {
if (itemToDelete.type === 'article') { if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id); await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else { } else {
@@ -102,18 +96,32 @@ 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':
case 'live': case 'live':
return 'bg-green-100 text-green-800 border-green-200'; return 'bg-green-500 text-white border-2 border-foreground';
case 'draft': case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200'; return 'bg-yellow-400 text-black border-2 border-foreground';
case 'archived': case 'archived':
case 'ended': case 'ended':
return 'bg-gray-100 text-gray-800 border-gray-200'; return 'bg-gray-400 text-white border-2 border-foreground';
default: default:
return 'bg-blue-100 text-blue-800 border-blue-200'; return 'bg-blue-500 text-white border-2 border-foreground';
} }
}; };
@@ -129,110 +137,125 @@ export function AdminDashboardComponent() {
}; };
return ( return (
<div className="py-8 space-y-8"> <div className="space-y-8">
<div className="flex justify-between items-center"> <div className="border-b-4 border-foreground pb-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1> <h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
<p className="text-muted-foreground"> <p className="font-body text-muted-foreground mt-1">
Управување со сите написи и live блогови Управување со сите написи и live блогови
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-3 flex-wrap">
<Button <Button
variant={showArchived ? "default" : "outline"} variant={showArchived ? 'brutal' : 'brutalOutline'}
onClick={() => setShowArchived(!showArchived)} onClick={() => setShowArchived(!showArchived)}
> >
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'} {showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button> </Button>
<Button asChild variant="outline"> <Button asChild variant="brutalAccent">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link> <Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button> </Button>
<Button asChild> <Button asChild variant="brutalOutline">
<Link to="/">Назад кон сајтот</Link> <Link to="/">Назад кон сајтот</Link>
</Button> </Button>
</div> </div>
</div> </div>
</div>
{!showArchived && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Live Blogs Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredLiveBlogs.length || 0} {liveBlogs.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите live блогови со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingLiveBlogs ? ( {loadingLiveBlogs ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredLiveBlogs.length === 0 ? ( ) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'} {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p> </p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> liveBlogs.map((blog) => (
{filteredLiveBlogs.map((blog) => ( <div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div <div className="flex items-start justify-between gap-3">
key={blog.id} <div className="flex-1 min-w-0">
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" <div className="flex items-center gap-2 mb-2 flex-wrap">
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link <Link
to="/admin/live-blogs/$slug" to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline" className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
> >
{blog.title} {blog.title}
</Link> </Link>
<Badge variant="outline" className={getStatusColor(blog.status)}> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
{getStatusText(blog.status)} {getStatusText(blog.status)}
</Badge> </span>
{blog.isPinned && ( {blog.isPinned && (
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200"> <span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
Закачено Закачено
</Badge> </span>
)} )}
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {blog.slug}</span> <span>Слаг: {blog.slug}</span>
<span>•</span> <span>•</span>
<span> <span>{format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
</span>
<span>•</span> <span>•</span>
<span>Прегледи: {blog.viewCount}</span> <span>Прегледи: {blog.viewCount}</span>
</div> </div>
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-1 flex-shrink-0">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="brutalOutline">
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link> <Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank"> <Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
Преглед
</Link>
</Button> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('liveBlog', blog.id)} onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -241,7 +264,7 @@ export function AdminDashboardComponent() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)} onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -259,91 +282,91 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))} ))
</div>
)} )}
</CardContent> </div>
</Card> </div>
{/* Articles Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани написи' : 'Написи'}
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredArticles.length || 0} {articles.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите написи со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingArticles ? ( {loadingArticles ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredArticles.length === 0 ? ( ) : articles.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'} {showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p> </p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link>
</Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> articles.map((article) => (
{filteredArticles.map((article) => ( <div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div <div className="flex items-start justify-between gap-3">
key={article.id} <div className="flex-1 min-w-0">
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" <div className="flex items-center gap-2 mb-2 flex-wrap">
>
<div className="flex items-start justify-between">
<div className="flex-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-display text-lg hover:underline hover:text-accent-foreground truncate"
> >
{article.title} {article.title}
</Link> </Link>
<Badge variant="outline" className={getStatusColor(article.status)}> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
{getStatusText(article.status)} {getStatusText(article.status)}
</Badge> </span>
{article.isHero && (
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
★ Hero
</span>
)}
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {article.slug}</span> <span>Слаг: {article.slug}</span>
<span>•</span> <span>•</span>
<span> <span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
</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 font-body text-muted-foreground line-clamp-2">
{article.excerpt} {article.excerpt}
</p> </p>
)} )}
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-1 flex-shrink-0 flex-col">
<Button asChild size="sm" variant="outline"> <div className="flex gap-1">
<Button asChild size="sm" variant="brutalOutline">
<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>
Преглед </Button>
</Link> </div>
<div className="flex gap-1">
<Button
size="sm"
variant={article.isHero ? 'brutal' : 'brutalOutline'}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set Hero'}
</Button> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('article', article.id)} onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -352,7 +375,7 @@ export function AdminDashboardComponent() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('article', article.id, article.title)} onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -370,70 +393,136 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))}
</div> </div>
))
)} )}
</CardContent> </div>
</Card> </div>
</div> </div>
{/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && ( {!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="border-brutal bg-card p-6">
<Card> <h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
<CardContent className="pt-6"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="text-2xl font-bold"> <div className="border-brutal-sm bg-background p-4">
{liveBlogs.filter(b => b.status === 'live').length || 0} <div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.filter(a => a.status === 'published').length || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Објавени написи</p> <div className="border-brutal-sm bg-background p-4">
</CardContent> <div className="text-3xl font-display">
</Card> {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
<Card> </div>
<CardContent className="pt-6"> <p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
<div className="text-2xl font-bold"> </div>
{liveBlogs.filter(b => b.isPinned).length || 0} <div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) =>
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
</div>
</div>
<div>
<h3 className="text-xl font-display uppercase 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-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-display text-lg hover:underline hover:text-accent-foreground"
>
{article.title}
</Link>
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
<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 flex-wrap">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-blue-600"></span>
FB: {article.facebookShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-sky-500"></span>
X: {article.twitterShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500"></span>
WA: {article.whatsappShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-cyan-500"></span>
TG: {article.telegramShares || 0}
</span>
</div>
</div>
</div>
</div>
);
})}
</div> </div>
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div> </div>
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</CardContent>
</Card>
</div> </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/60 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> <div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2"> <h3 className="text-2xl font-display uppercase mb-3">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'} {dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3> </h3>
<p className="text-muted-foreground mb-4"> <p className="font-body text-muted-foreground mb-6">
{dialogType === 'delete' {dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?` ? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`} : `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-3">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}> <Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи Откажи
</Button> </Button>
<Button <Button
variant={dialogType === 'delete' ? 'destructive' : 'default'} variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
onClick={handleConfirmAction} onClick={handleConfirmAction}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -447,4 +536,4 @@ export function AdminDashboardComponent() {
)} )}
</div> </div>
); );
} }
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArchiveComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} views</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ArtComponent() {
return (
<CategoryPage
categorySlug="art"
categoryName="Уметност"
categoryDescription="Уметност, култура и забава"
/>
)
}
@@ -7,6 +7,7 @@ import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils' import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
import { CommentSection } from '@/components/features/comments/CommentSection' import { CommentSection } from '@/components/features/comments/CommentSection'
import { ReactionButtons } from '@/components/features/comments/ReactionButtons' import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArticleDetailComponent({ id }: { id: string }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -71,6 +72,18 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)} )}
</div> </div>
{/* Social Sharing */}
<div className="mb-8">
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
/>
</div>
{data.featuredImage && data.imagePosition !== 'none' && ( {data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${ <div className={`relative mb-4 ${
data.imagePosition === 'top' data.imagePosition === 'top'
@@ -168,6 +181,22 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
{/* Social Sharing Footer */}
<div className="mt-8 pt-8 border-t">
<div className="flex flex-col items-center">
<p className="text-sm text-muted-foreground mb-4">Share this article:</p>
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
variant="footer"
/>
</div>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3> <h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
@@ -1,72 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticlesComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={article.id}
to={`/articles/${article.id}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
interface CategoryPageProps {
categorySlug: string
categoryName: string
categoryDescription?: string
}
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
queryKey: ['category-articles', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
})
const { data: heroData, isLoading: heroLoading } = useQuery({
queryKey: ['category-hero', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
enabled: !!categorySlug,
})
const heroArticle = heroData?.data?.[0]
const articles = articlesData?.data || []
if (articlesLoading || heroLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Вчитување...</div>
</div>
)
}
if (articlesError) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Category Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold">{categoryName}</h1>
{categoryDescription && (
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
)}
</div>
{/* Hero Section with Pinned Live Blogs Sidebar */}
{heroArticle && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
{/* Hero Article - 2/3 width */}
<div className="lg:col-span-2">
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
<Link to={`/articles/${heroArticle.id}`} className="block">
{heroArticle.featuredImage ? (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={heroArticle.featuredImage}
alt={heroArticle.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
) : (
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
</Link>
<div className="p-6">
<Link to={`/articles/${heroArticle.id}`} className="block">
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
{heroArticle.title}
</h2>
{heroArticle.excerpt && (
<p className="text-muted-foreground mb-4 line-clamp-3">
{heroArticle.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{heroArticle.views} прегледи</span>
</div>
<SocialShareButtons
articleId={heroArticle.id}
title={heroArticle.title}
url={`${window.location.origin}/articles/${heroArticle.id}`}
excerpt={heroArticle.excerpt}
image={heroArticle.featuredImage}
tags={heroArticle.tags}
variant="compact"
/>
</div>
</div>
</div>
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
)}
{/* Articles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{articles.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Нема објавени статии во оваа категорија. Проверете подоцна!
</p>
</div>
)}
</div>
)
}
@@ -38,7 +38,8 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => ( {data?.data.map((liveBlog) => (
<Link <Link
key={liveBlog.id} key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}`} to="/live-blogs/$slug"
params={{ slug: liveBlog.slug }}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
@@ -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 type { 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,
};
}
+11 -11
View File
@@ -1,25 +1,25 @@
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-11 px-6 py-2",
sm: "h-9 rounded-md px-3", sm: "h-9 px-4 py-1",
lg: "h-11 rounded-md px-8", lg: "h-14 px-8 text-base",
icon: "h-10 w-10", icon: "h-12 w-12",
}, },
}, },
defaultVariants: { defaultVariants: {
+5 -5
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "border-brutal bg-card text-card-foreground",
className className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-display leading-none tracking-tight",
className className
)} )}
{...props} {...props}
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm font-body text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@@ -69,7 +69,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
+101
View File
@@ -0,0 +1,101 @@
import { useState, useCallback } from 'react';
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
interface UseSocialShareOptions {
onSuccess?: (platform: SharePlatform) => void;
onError?: (platform: SharePlatform, error: Error) => void;
onCopySuccess?: () => void;
onCopyError?: (error: Error) => void;
}
interface UseSocialShareReturn {
isSharing: boolean;
isCopying: boolean;
lastSharedPlatform: SharePlatform | null;
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
copyLink: (url: string) => Promise<boolean>;
}
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
const [isSharing, setIsSharing] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
const share = useCallback(async (
platform: SharePlatform,
data: ShareData & { articleId: string }
) => {
setIsSharing(true);
setLastSharedPlatform(platform);
try {
// Track the share event
await trackShare({
articleId: data.articleId,
platform,
userAgent: navigator.userAgent,
});
// Call success callback
if (options.onSuccess) {
options.onSuccess(platform);
}
// Open share URL for social platforms (not for 'link')
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error(`Failed to share on ${platform}:`, error);
// Call error callback
if (options.onError) {
options.onError(platform, error as Error);
}
// Still open the share URL even if tracking fails (for social platforms)
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsSharing(false);
}
}, [options]);
const copyLink = useCallback(async (url: string): Promise<boolean> => {
setIsCopying(true);
try {
const success = await copyToClipboard(url);
if (success && options.onCopySuccess) {
options.onCopySuccess();
} else if (!success && options.onCopyError) {
options.onCopyError(new Error('Failed to copy to clipboard'));
}
return success;
} catch (error) {
console.error('Failed to copy link:', error);
if (options.onCopyError) {
options.onCopyError(error as Error);
}
return false;
} finally {
setIsCopying(false);
}
}, [options]);
return {
isSharing,
isCopying,
lastSharedPlatform,
share,
copyLink,
};
}
+41 -41
View File
@@ -1,51 +1,51 @@
/* @tailwind base; */ @tailwind base;
/* @tailwind components; */ @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 35 20% 95%;
--card: 222.2 84% 4.9%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 35 20% 95%;
--popover: 222.2 84% 4.9%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 35 20% 95%;
--primary: 210 40% 98%; --primary: 35 20% 95%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 35 20% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 70 100% 50%;
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 8%;
--destructive: 0 62.8% 30.6%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%; --border: 35 20% 95%;
--input: 217.2 32.6% 17.5%; --input: 35 20% 95%;
--ring: 212.7 26.8% 83.9%; --ring: 70 100% 50%;
} }
} }
+96
View File
@@ -0,0 +1,96 @@
import { type SharePlatform } from './social-utils';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface TrackShareParams {
articleId: string;
platform: SharePlatform;
userAgent?: string;
ipAddress?: string;
}
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
console.warn('Failed to track share event:', response.statusText);
return false;
}
return true;
} catch (error) {
console.error('Error tracking share event:', error);
return false;
}
};
export const getShareStats = async (articleId?: string) => {
try {
const url = articleId
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
: `${API_BASE_URL}/api/v1/analytics/shares`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for admin auth
});
if (!response.ok) {
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching share stats:', error);
throw error;
}
};
export const getTopSharedArticles = async (limit: number = 10) => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching top shared articles:', error);
throw error;
}
};
export const getTotalShareStats = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching total share stats:', error);
throw error;
}
};
+45 -5
View File
@@ -73,6 +73,18 @@ export interface Article {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isHero?: boolean;
isPinned?: boolean;
facebookShares?: number;
twitterShares?: number;
whatsappShares?: number;
telegramShares?: number;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
twitterTitle?: string;
twitterDescription?: string;
twitterImage?: string;
} }
export interface ArticlesResponse { export interface ArticlesResponse {
@@ -126,6 +138,7 @@ export interface UpdateArticleDto {
videoUrl?: string; videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string; videoCaption?: string;
isHero?: boolean;
} }
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
@@ -360,8 +373,12 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
return response.json(); return response.json();
} }
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> { export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
const endpoint = isUuid
? `${API_BASE_URL}/live-blogs/${slugOrId}`
: `${API_BASE_URL}/live-blogs/slug/${slugOrId}`;
const response = await authFetch(endpoint);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -398,6 +415,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) {
@@ -599,6 +633,7 @@ export interface FindCommentsParams {
articleId?: string; articleId?: string;
liveBlogId?: string; liveBlogId?: string;
parentCommentId?: string; parentCommentId?: string;
parentId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -646,15 +681,20 @@ interface BackendComment {
// Recursive function to map comment and its replies // Recursive function to map comment and its replies
function mapBackendComment(comment: BackendComment): Comment { function mapBackendComment(comment: BackendComment): Comment {
const mappedComment: Comment = { const mappedComment: Comment = {
...comment, id: comment.id,
content: comment.content,
articleId: comment.articleId,
liveBlogId: comment.liveBlogId,
parentCommentId: comment.parentId, parentCommentId: comment.parentId,
// Ensure reactions object exists userId: comment.userId,
isVisible: comment.isVisible,
reactions: { reactions: {
likes: comment.likeCount || 0, likes: comment.likeCount || 0,
dislikes: comment.dislikeCount || 0, dislikes: comment.dislikeCount || 0,
}, },
// Recursively map replies if they exist
replies: comment.replies?.map(mapBackendComment) || [], replies: comment.replies?.map(mapBackendComment) || [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}; };
return mappedComment; return mappedComment;
} }
+124
View File
@@ -0,0 +1,124 @@
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
export interface ShareData {
title: string;
url: string;
excerpt?: string;
image?: string;
tags?: string[];
}
export const getShareUrl = (
platform: Exclude<SharePlatform, 'link'>,
data: ShareData
): string => {
const { title, url, excerpt } = data;
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
switch (platform) {
case 'facebook':
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
case 'twitter':
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
case 'whatsapp':
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
case 'telegram':
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
case 'email':
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
default:
return url;
}
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
return false;
}
};
export const getPlatformIcon = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'MessageCircle';
case 'telegram':
return 'Send';
case 'email':
return 'Mail';
case 'link':
return 'Link';
default:
return 'Share2';
}
};
export const getPlatformLabel = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'WhatsApp';
case 'telegram':
return 'Telegram';
case 'email':
return 'Email';
case 'link':
return 'Copy Link';
default:
return 'Share';
}
};
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
const { title, excerpt, image, url } = data;
// Default values if not provided
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
const ogImage = image || '/placeholder-image.jpg';
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
return {
ogTitle,
ogDescription,
ogImage,
ogUrl,
ogType: 'article' as const,
twitterCard: 'summary_large_image' as const,
twitterTitle: ogTitle,
twitterDescription: ogDescription,
twitterImage: ogImage,
};
};
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Theme utilities for dark/light mode
*/
export type Theme = 'light' | 'dark';
/**
* Get the current theme from localStorage or system preference
*/
export function getInitialTheme(): Theme {
// Check localStorage first
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// Default to light
return 'light';
}
/**
* Apply theme to document
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Save to localStorage
localStorage.setItem('theme', theme);
}
/**
* Initialize theme on page load
*/
export function initializeTheme(): void {
const theme = getInitialTheme();
applyTheme(theme);
}
/**
* Toggle between light and dark themes
*/
export function toggleTheme(): Theme {
const currentTheme = getInitialTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
return newTheme;
}
/**
* Listen for system theme changes
*/
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a preference in localStorage
if (!localStorage.getItem('theme')) {
const theme = e.matches ? 'dark' : 'light';
callback(theme);
applyTheme(theme);
}
};
mediaQuery.addEventListener('change', handleChange);
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange);
}
+4
View File
@@ -4,6 +4,10 @@ import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme'
// Initialize theme before rendering
initializeTheme();
const queryClient = new QueryClient() const queryClient = new QueryClient()
+177 -127
View File
@@ -1,6 +1,6 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { ArticleTicker } from './components/ArticleTicker' import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent' import { ArchiveComponent } from './components/routes/ArchiveComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent' import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
@@ -8,17 +8,23 @@ 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 { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { SportComponent } from './components/routes/SportComponent'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
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 { Button } from './components/ui/button'
import { Zap, Search, Users } from 'lucide-react'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
head: () => ({ head: () => ({
meta: [ meta: [
{ {
title: 'Placebo.mk - Sarcastic News from Macedonia', title: 'Placebo.mk - Сатирични вести од Македонија',
description: 'Latest news and articles from Macedonia with a sarcastic twist', description: 'Latest news and articles from Macedonia with a sarcastic twist',
}, },
], ],
@@ -26,14 +32,48 @@ const rootRoute = createRootRoute({
component: () => ( component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col"> <div className="min-h-screen bg-background text-foreground flex flex-col">
<Header /> <Header />
<ArticleTicker />
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8"> <main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t mt-12"> <footer className="border-t-4 border-foreground bg-foreground text-background">
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground"> <div className="container mx-auto max-w-6xl px-4 py-12">
© 2025 Placebo.mk. Sarcastic news from Macedonia. <div className="grid md:grid-cols-3 gap-8">
<div>
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
<p className="font-body text-sm text-background/70">
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
</p>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Категории</h4>
<ul className="space-y-2 font-body text-sm">
<li><Link to="/sport" className="hover:text-accent transition-colors">Спорт</Link></li>
<li><Link to="/art" className="hover:text-accent transition-colors">Уметност</Link></li>
<li><Link to="/science" className="hover:text-accent transition-colors">Наука</Link></li>
<li><Link to="/archive" className="hover:text-accent transition-colors">Архива</Link></li>
</ul>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Следете не</h4>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Zap className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Search className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Users className="w-5 h-5" />
</a>
</div>
</div>
</div>
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -44,141 +84,72 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: () => ( component: () => (
<div className="space-y-6"> <div>
{/* Article Ticker at the top */}
<ArticleTicker />
{/* Live Blog Ticker below article ticker */}
<LiveBlogTicker className="mt-4" />
<div className="py-8 md:py-12"> <div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div className="lg:col-span-2">
{/* Main content - 3 columns */} <HeroArticle />
<div className="lg:col-span-3 space-y-8">
{/* Hero section */}
<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>
<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> </div>
{/* Features grid */} <div className="lg:col-span-1">
<div className="grid md:grid-cols-3 gap-6"> <PinnedLiveBlogsSidebar />
<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>
</div> </div>
{/* Call to action */} <LatestArticlesGrid />
<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> <div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto"> <div className="text-center">
Dive into our articles or follow live coverage of breaking news as it happens. <h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
Непристојно сатрирични вести и коментари за локални и глобални настани во Македонија.
Затоа што понекогаш вистината боли повеќе од фикцијата.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link to="/archive">
to="/articles" <Button variant="brutalAccent" className="gap-2">
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2" Прелистај архива
> </Button>
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>
<Link <Link to="/live-blogs">
to="/live-blogs" <Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
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" <Zap className="w-4 h-4" />
> Live Блогови
View Live Blogs </Button>
<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> </Link>
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar - 1 column */} <div className="grid md:grid-cols-3 gap-6 mt-16">
<div className="lg:col-span-1"> <div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
<PinnedLiveBlogSidebar /> <div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<Zap className="w-6 h-6" />
{/* Additional sidebar content */} </div>
<div className="mt-6 rounded-xl border bg-card p-6"> <h3 className="text-2xl font-display mb-2">Најнови вести</h3>
<h3 className="font-semibold mb-4">About Placebo.mk</h3> <p className="font-body text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground mb-4"> Свежо подготвена сатира за тековни настани, политика и сè помеѓу тоа.
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
</p> </p>
<div className="text-xs text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Live coverage updated in real-time</span>
</div>
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>No ads, no sponsors, no BS</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span>100% independent journalism</span>
</div> </div>
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-2">
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<span className="font-display text-2xl">⚠</span>
</div> </div>
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
<p className="font-body text-sm text-muted-foreground">
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
</p>
</div> </div>
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-3">
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<Users className="w-6 h-6" />
</div> </div>
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
<p className="font-body text-sm text-muted-foreground">
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -186,10 +157,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 +188,64 @@ const articleDetailRoute = createRoute({
const { id } = articleDetailRoute.useParams() const { id } = articleDetailRoute.useParams()
return <ArticleDetailComponent id={id} /> return <ArticleDetailComponent id={id} />
}, },
loader: async ({ params }) => {
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' },
],
}
}
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 = [
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
{ 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' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
]
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name })
}
if (article.tags && article.tags.length > 0) {
article.tags.forEach((tag: string) => {
metaTags.push({ property: 'article:tag', content: tag })
})
}
return {
meta: metaTags,
}
},
}) })
const liveBlogsRoute = createRoute({ const liveBlogsRoute = createRoute({
@@ -257,7 +304,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,
+318 -61
View File
@@ -1,72 +1,162 @@
@import "tailwindcss"; @import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@theme { @theme {
--color-primary: oklch(0.647 0.22 0.23); --color-primary: oklch(0.08 0 0);
--color-primary-foreground: oklch(0.985 0.002 0); --color-primary-foreground: oklch(0.98 0 0);
--color-secondary: oklch(0.97 0.002 0); --color-secondary: oklch(0.93 0.02 50);
--color-secondary-foreground: oklch(0.205 0.02 266.5); --color-secondary-foreground: oklch(0.08 0 0);
--color-muted: oklch(0.97 0 0); --color-muted: oklch(0.91 0.01 50);
--color-muted-foreground: oklch(0.556 0 0); --color-muted-foreground: oklch(0.45 0.01 50);
--color-accent: oklch(0.97 0 0); --color-accent: oklch(0.93 0.8 100);
--color-accent-foreground: oklch(0.205 0.02 266.5); --color-accent-foreground: oklch(0.08 0 0);
--color-destructive: oklch(0.55 0.22 25); --color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0.002 0); --color-destructive-foreground: oklch(0.98 0 0);
--color-border: oklch(0.9 0 0); --color-border: oklch(0.08 0 0);
--color-input: oklch(0.9 0 0); --color-input: oklch(0.08 0 0);
--color-ring: oklch(0.647 0.22 0.23); --color-ring: oklch(0.93 0.8 100);
--radius: 0.5rem; --radius: 0px;
--font-sans: "Inter", sans-serif; --font-display: "Bebas Neue", sans-serif;
--font-body: "IBM Plex Mono", monospace;
} }
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
--shadow-brutal: 4px 4px 0px 0px oklch(0.08 0 0);
--shadow-brutal-sm: 2px 2px 0px 0px oklch(0.08 0 0);
--shadow-brutal-lg: 6px 6px 0px 0px oklch(0.08 0 0);
--shadow-brutal-accent: 4px 4px 0px 0px oklch(0.93 0.8 100);
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 35 20% 95%;
--card: 222.2 84% 4.9%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 35 20% 95%;
--popover: 222.2 84% 4.9%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 35 20% 95%;
--primary: 210 40% 98%; --primary: 35 20% 95%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 35 20% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 70 100% 50%;
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 8%;
--destructive: 0 62.8% 30.6%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%; --border: 35 20% 95%;
--input: 217.2 32.6% 17.5%; --input: 35 20% 95%;
--ring: 212.7 26.8% 83.9%; --ring: 70 100% 50%;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-behavior: smooth;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body);
position: relative;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
::selection {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-border {
0%, 100% {
border-color: hsl(var(--border));
}
50% {
border-color: hsl(var(--accent));
}
}
@keyframes marquee { @keyframes marquee {
0% { 0% {
transform: translateX(0); transform: translateX(0);
@@ -76,18 +166,185 @@
} }
} }
@keyframes glitch {
0% {
clip-path: inset(40% 0 61% 0);
transform: translate(-2px, 2px);
}
20% {
clip-path: inset(92% 0 1% 0);
transform: translate(1px, -1px);
}
40% {
clip-path: inset(43% 0 1% 0);
transform: translate(-1px, 2px);
}
60% {
clip-path: inset(25% 0 58% 0);
transform: translate(2px, 1px);
}
80% {
clip-path: inset(54% 0 7% 0);
transform: translate(-2px, -1px);
}
100% {
clip-path: inset(58% 0 43% 0);
transform: translate(2px, 2px);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-right {
animation: slideInRight 0.5s ease-out forwards;
opacity: 0;
}
.animate-scale-in {
animation: scaleIn 0.4s ease-out forwards;
opacity: 0;
}
.animate-marquee {
display: flex;
animation: marquee 25s linear infinite;
width: max-content;
}
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.stagger-9 { animation-delay: 0.45s; }
.stagger-10 { animation-delay: 0.5s; }
.stagger-11 { animation-delay: 0.55s; }
.stagger-12 { animation-delay: 0.6s; }
.font-display {
font-family: var(--font-display);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.font-body {
font-family: var(--font-body);
}
.border-brutal {
border: 3px solid hsl(var(--border));
}
.border-brutal-sm {
border: 2px solid hsl(var(--border));
}
.border-brutal-accent {
border: 3px solid hsl(var(--accent));
border-left-width: 6px;
}
.shadow-brutal {
box-shadow: var(--shadow-brutal);
}
.shadow-brutal-sm {
box-shadow: var(--shadow-brutal-sm);
}
.shadow-brutal-lg {
box-shadow: var(--shadow-brutal-lg);
}
.shadow-brutal-accent {
box-shadow: var(--shadow-brutal-accent);
}
.hover\:shadow-brutal:hover {
box-shadow: var(--shadow-brutal);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.hover\:shadow-brutal-accent:hover {
box-shadow: var(--shadow-brutal-accent);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.bg-noise {
position: relative;
}
.bg-noise::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
}
.text-outline {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.text-outline-hover:hover {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@layer base { @layer base {
* { * {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.animate-marquee { h1, h2, h3, h4, h5, h6 {
display: flex; font-family: var(--font-display);
animation: marquee 30s linear infinite; letter-spacing: 0.02em;
width: max-content; text-transform: uppercase;
} }
} }
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: hsl(var(--secondary));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border: 2px solid hsl(var(--secondary));
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent));
}
+1
View File
@@ -0,0 +1 @@
docker-compose -f docker-compose.dev.yml up -d
+6 -8
View File
@@ -1,9 +1,7 @@
1. role based auth [admin, contributor, user] 5. admin dashboard enhancement
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality
lets implement a role based auth lets add sport, art, science pages. Rename current /articles page to /archive.
admin can create, edit and delete articles and liveblogs create corresponding categories so article will be placed on page according
admin can create contributors to category.
contributors can create, edit and delete articles and liveblogs on home page under a hero section create a card grid component 4x3 where last published article would be placed regardless of category.
user can react(like, dislike) to articles and write comments every page will have simular design as home page with minor design differences.