category field added

implemented ctegory logic, ditional pages added
This commit is contained in:
echo 2026-02-06 03:35:28 +01:00
parent add12b2fbf
commit 46eb41aaa5
16 changed files with 598 additions and 21 deletions

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",

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();

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),
], ],

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}`;
private async findOrCreateCategory(
categorySlug: string,
): Promise<Category | null> {
if (!categorySlug) {
return null;
} }
return headers;
// Map CMS category slugs to Macedonian display names
const categoryMap: Record<string, { name: string; description: string }> = {
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
science: { name: 'Наука', description: 'Научни откритија и технологија' },
};
const categoryInfo = categoryMap[categorySlug];
if (!categoryInfo) {
this.logger.warn(`Unknown category slug: ${categorySlug}`);
return null;
}
// Try to find existing category by slug
let category = await this.categoryRepository.findOne({
where: { slug: categorySlug },
});
if (!category) {
// Create new category
category = this.categoryRepository.create({
name: categoryInfo.name,
slug: categorySlug,
description: categoryInfo.description,
order: 0,
});
category = await this.categoryRepository.save(category);
this.logger.log(
`Created new category: ${categorySlug} (${categoryInfo.name})`,
);
}
return category;
} }
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
@ -181,6 +226,17 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@ -198,6 +254,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@ -256,6 +313,17 @@ export class StrapiService {
const imageUrl = this.extractImageUrl(strapiArticle); const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@ -270,6 +338,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@ -333,6 +402,17 @@ export class StrapiService {
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@ -346,6 +426,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@ -400,6 +481,17 @@ export class StrapiService {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@ -412,6 +504,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(

View File

@ -63,6 +63,12 @@
"videoCaption": { "videoCaption": {
"type": "string", "type": "string",
"default": "" "default": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
} }
} }
} }

View File

@ -28,7 +28,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: placebo-backend container_name: placebo-backend
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_TYPE: postgres DATABASE_TYPE: postgres
DATABASE_HOST: postgres DATABASE_HOST: postgres

View File

@ -0,0 +1,147 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function LatestArticlesGrid() {
const { data, isLoading, error } = useQuery({
queryKey: ['latest-articles'],
queryFn: () => api.fetchLatestArticles(12),
})
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="p-6 rounded-xl border bg-card animate-pulse">
<div className="h-48 bg-muted rounded-lg mb-4"></div>
<div className="h-4 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div className="h-3 bg-muted rounded mb-4 w-1/2"></div>
<div className="flex justify-between">
<div className="h-3 bg-muted rounded w-1/4"></div>
<div className="h-3 bg-muted rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="rounded-xl border border-dashed border-red-200 bg-red-50 p-8 text-center">
<div className="text-red-500 mb-2">Грешка при вчитување на статии</div>
<p className="text-sm text-red-600">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="rounded-xl border border-dashed border-muted p-8 text-center">
<div className="text-muted-foreground mb-2">Нема објавени статии</div>
<p className="text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Најнови статии</h2>
<Link
to="/archive"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Види сите
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map((article) => (
<div
key={article.id}
className="group p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
{article.featuredImage ? (
<div className="relative h-48 overflow-hidden rounded-lg mb-4">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
) : (
<div className="h-48 bg-gradient-to-br from-primary/10 to-primary/5 rounded-lg mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
<h3 className="text-lg font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span></span>
<span>{article.views} прегледи</span>
</div>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
{article.category.name}
</Link>
)}
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -19,8 +19,11 @@ export function Header() {
}; };
const navLinks = [ const navLinks = [
{ to: '/', label: 'Home' }, { to: '/', label: 'Почетна' },
{ to: '/articles', label: 'Articles' }, { to: '/sport', label: 'Спорт' },
{ to: '/art', label: 'Уметност' },
{ to: '/science', label: 'Наука' },
{ to: '/archive', label: 'Архива' },
{ to: '/live-blogs', label: 'Live' }, { to: '/live-blogs', label: 'Live' },
]; ];

View File

@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share' import { SocialShareButtons } from '@/components/features/social-share'
export function ArticlesComponent() { export function ArchiveComponent() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['articles'], queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }), queryFn: () => api.fetchArticles({ status: 'published' }),

View File

@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ArtComponent() {
return (
<CategoryPage
categorySlug="art"
categoryName="Уметност"
categoryDescription="Уметност, култура и забава"
/>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ScienceComponent() {
return (
<CategoryPage
categorySlug="science"
categoryName="Наука"
categoryDescription="Научни откритија и технологија"
/>
)
}

View File

@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function SportComponent() {
return (
<CategoryPage
categorySlug="sport"
categoryName="Спорт"
categoryDescription="Спортски вести и анализи"
/>
)
}

View File

@ -407,6 +407,14 @@ export async function fetchHeroArticle(): Promise<Article | null> {
return article || null; 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) {

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,11 +8,15 @@ import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminCompone
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { AuthPage } from './components/routes/AuthPage' import { AuthPage } from './components/routes/AuthPage'
import { SportComponent } from './components/routes/SportComponent'
import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { 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 { HeroArticle } from './components/home/HeroArticle'
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar' import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@ -67,6 +71,11 @@ const indexRoute = createRoute({
</div> </div>
</div> </div>
{/* Latest Articles Grid - 4x3 */}
<div className="mb-12">
<LatestArticlesGrid />
</div>
{/* Brand Introduction */} {/* Brand Introduction */}
<div className="rounded-xl border bg-card p-8 text-center mb-12"> <div className="rounded-xl border bg-card p-8 text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
@ -141,10 +150,10 @@ const indexRoute = createRoute({
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link
to="/articles" to="/archive"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2" className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
> >
Read Articles Прелистај архива
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<path d="M5 12h14" /> <path d="M5 12h14" />
<path d="m12 5 7 7-7 7" /> <path d="m12 5 7 7-7 7" />
@ -168,10 +177,28 @@ const indexRoute = createRoute({
), ),
}) })
const articlesRoute = createRoute({ const archiveRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/articles', path: '/archive',
component: ArticlesComponent, component: ArchiveComponent,
})
const sportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sport',
component: SportComponent,
})
const artRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/art',
component: ArtComponent,
})
const scienceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/science',
component: ScienceComponent,
}) })
const articleDetailRoute = createRoute({ const articleDetailRoute = createRoute({
@ -308,7 +335,10 @@ const adminDashboardRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
articlesRoute, archiveRoute,
sportRoute,
artRoute,
scienceRoute,
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,

View File

@ -1,6 +1,7 @@
5. admin dashboard enhancement 5. admin dashboard enhancement
6. homepage:
lets add hero section on the home page, next to the hero section [2/3 width] place pinned
live blogs. in the hero section we will show latest article with tag: hero.
make apropriate changes to database schema and backend as needed.
lets add sport, art, science pages. Rename current /articles page to /archive.
create corresponding categories so article will be placed on page according
to category.
on home page under a hero section create a card grid component 4x3 where last published article would be placed regardless of category.
every page will have simular design as home page with minor design differences.