From 46eb41aaa523717821ed888649b8bca8b46fabbb Mon Sep 17 00:00:00 2001 From: echo Date: Fri, 6 Feb 2026 03:35:28 +0100 Subject: [PATCH] category field added implemented ctegory logic, ditional pages added --- backend/package.json | 3 +- backend/scripts/seed-categories.ts | 65 ++++++ backend/src/modules/strapi.module.ts | 3 + backend/src/modules/strapi.service.ts | 103 +++++++++- .../article/content-types/article/schema.json | 6 + docker-compose.yml | 2 +- .../components/home/LatestArticlesGrid.tsx | 147 ++++++++++++++ frontend/src/components/layout/Header.tsx | 7 +- ...clesComponent.tsx => ArchiveComponent.tsx} | 2 +- .../src/components/routes/ArtComponent.tsx | 11 ++ .../src/components/routes/CategoryPage.tsx | 187 ++++++++++++++++++ .../components/routes/ScienceComponent.tsx | 11 ++ .../src/components/routes/SportComponent.tsx | 11 ++ frontend/src/lib/api.ts | 8 + frontend/src/routes.tsx | 44 ++++- todos.md | 9 +- 16 files changed, 598 insertions(+), 21 deletions(-) create mode 100644 backend/scripts/seed-categories.ts create mode 100644 frontend/src/components/home/LatestArticlesGrid.tsx rename frontend/src/components/routes/{ArticlesComponent.tsx => ArchiveComponent.tsx} (98%) create mode 100644 frontend/src/components/routes/ArtComponent.tsx create mode 100644 frontend/src/components/routes/CategoryPage.tsx create mode 100644 frontend/src/components/routes/ScienceComponent.tsx create mode 100644 frontend/src/components/routes/SportComponent.tsx diff --git a/backend/package.json b/backend/package.json index 7b768c9..1617ebe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,8 @@ "dev:docker": "nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch", "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": { "@nestjs/axios": "^4.0.1", diff --git a/backend/scripts/seed-categories.ts b/backend/scripts/seed-categories.ts new file mode 100644 index 0000000..ded38cb --- /dev/null +++ b/backend/scripts/seed-categories.ts @@ -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(); \ No newline at end of file diff --git a/backend/src/modules/strapi.module.ts b/backend/src/modules/strapi.module.ts index fd209e3..c5b2b7d 100644 --- a/backend/src/modules/strapi.module.ts +++ b/backend/src/modules/strapi.module.ts @@ -1,13 +1,16 @@ import { Module, forwardRef } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { StrapiService } from './strapi.service'; import { StrapiController } from './strapi.controller'; import { ArticlesModule } from './articles.module'; import { LiveBlogModule } from './live-blog.module'; +import { Category } from './entities'; @Module({ imports: [ HttpModule, + TypeOrmModule.forFeature([Category]), forwardRef(() => ArticlesModule), forwardRef(() => LiveBlogModule), ], diff --git a/backend/src/modules/strapi.service.ts b/backend/src/modules/strapi.service.ts index ebb9986..de8ac5b 100644 --- a/backend/src/modules/strapi.service.ts +++ b/backend/src/modules/strapi.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { lastValueFrom } from 'rxjs'; import { ArticlesService } from './articles.service'; import { LiveBlogService } from './live-blog.service'; @@ -11,6 +13,7 @@ import { ImagePosition, ImageSize, VideoPosition, + Category, } from './entities'; interface StrapiImage { @@ -45,6 +48,7 @@ interface StrapiArticle { videoUrl?: string; videoPosition?: string; videoCaption?: string; + category?: string; } interface StrapiLiveBlog { @@ -64,6 +68,7 @@ interface StrapiLiveBlog { videoUrl?: string; videoPosition?: string; videoCaption?: string; + category?: string; } interface StrapiResponse { @@ -91,6 +96,8 @@ export class StrapiService { private readonly articlesService: ArticlesService, @Inject(forwardRef(() => LiveBlogService)) private readonly liveBlogService: LiveBlogService, + @InjectRepository(Category) + private readonly categoryRepository: Repository, ) { this.strapiUrl = this.configService.get('STRAPI_URL') || 'http://localhost:1337'; @@ -99,13 +106,51 @@ export class StrapiService { } private getHeaders() { - const headers: Record = { - 'Content-Type': 'application/json', + return { + Authorization: `Bearer ${this.strapiApiToken}`, }; - if (this.strapiApiToken) { - headers['Authorization'] = `Bearer ${this.strapiApiToken}`; + } + + private async findOrCreateCategory( + categorySlug: string, + ): Promise { + if (!categorySlug) { + return null; } - return headers; + + // Map CMS category slugs to Macedonian display names + const categoryMap: Record = { + 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 { @@ -181,6 +226,17 @@ export class StrapiService { for (const strapiArticle of strapiArticles) { 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 = { title: strapiArticle.title, excerpt: strapiArticle.description, @@ -198,6 +254,7 @@ export class StrapiService { videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', + categoryId, }; await this.articlesService.syncFromStrapi( @@ -256,6 +313,17 @@ export class StrapiService { 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 = { title: strapiArticle.title, excerpt: strapiArticle.description, @@ -270,6 +338,7 @@ export class StrapiService { videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', + categoryId, }; await this.articlesService.syncFromStrapi( @@ -333,6 +402,17 @@ export class StrapiService { for (const strapiLiveBlog of strapiLiveBlogs) { 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 = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, @@ -346,6 +426,7 @@ export class StrapiService { videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', + categoryId, }; await this.liveBlogService.syncFromStrapi( @@ -400,6 +481,17 @@ export class StrapiService { 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 = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, @@ -412,6 +504,7 @@ export class StrapiService { videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', + categoryId, }; await this.liveBlogService.syncFromStrapi( diff --git a/cms/cms/src/api/article/content-types/article/schema.json b/cms/cms/src/api/article/content-types/article/schema.json index 39a0d34..1bd0816 100644 --- a/cms/cms/src/api/article/content-types/article/schema.json +++ b/cms/cms/src/api/article/content-types/article/schema.json @@ -63,6 +63,12 @@ "videoCaption": { "type": "string", "default": "" + }, + "category": { + "type": "enumeration", + "enum": ["sport", "art", "science"], + "default": "sport", + "required": true } } } diff --git a/docker-compose.yml b/docker-compose.yml index 74effe0..0149d89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: context: ./backend dockerfile: Dockerfile container_name: placebo-backend - environment: + environment: NODE_ENV: production DATABASE_TYPE: postgres DATABASE_HOST: postgres diff --git a/frontend/src/components/home/LatestArticlesGrid.tsx b/frontend/src/components/home/LatestArticlesGrid.tsx new file mode 100644 index 0000000..4a432f8 --- /dev/null +++ b/frontend/src/components/home/LatestArticlesGrid.tsx @@ -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 ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) + } + + if (error) { + return ( +
+
Грешка при вчитување на статии
+

Обидете се повторно

+
+ ) + } + + const articles = data?.data || [] + + if (articles.length === 0) { + return ( +
+
Нема објавени статии
+

Проверете подоцна

+
+ ) + } + + return ( +
+
+

Најнови статии

+ + Види сите + + + + + +
+ +
+ {articles.map((article) => ( +
+ + {article.featuredImage ? ( +
+ {article.title} +
+
+ ) : ( +
+ + + + + + +
+ )} + +

+ {article.title} +

+ + {article.excerpt && ( +

+ {article.excerpt} +

+ )} + + +
+
+
+ + {new Date(article.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + {article.views} прегледи +
+ + {article.category && ( + + {article.category.name} + + )} +
+ + +
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 7cbe94a..cfd6aa7 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -19,8 +19,11 @@ export function Header() { }; const navLinks = [ - { to: '/', label: 'Home' }, - { to: '/articles', label: 'Articles' }, + { to: '/', label: 'Почетна' }, + { to: '/sport', label: 'Спорт' }, + { to: '/art', label: 'Уметност' }, + { to: '/science', label: 'Наука' }, + { to: '/archive', label: 'Архива' }, { to: '/live-blogs', label: 'Live' }, ]; diff --git a/frontend/src/components/routes/ArticlesComponent.tsx b/frontend/src/components/routes/ArchiveComponent.tsx similarity index 98% rename from frontend/src/components/routes/ArticlesComponent.tsx rename to frontend/src/components/routes/ArchiveComponent.tsx index 6d8286a..be98b33 100644 --- a/frontend/src/components/routes/ArticlesComponent.tsx +++ b/frontend/src/components/routes/ArchiveComponent.tsx @@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router' import * as api from '@/lib/api' import { SocialShareButtons } from '@/components/features/social-share' -export function ArticlesComponent() { +export function ArchiveComponent() { const { data, isLoading, error } = useQuery({ queryKey: ['articles'], queryFn: () => api.fetchArticles({ status: 'published' }), diff --git a/frontend/src/components/routes/ArtComponent.tsx b/frontend/src/components/routes/ArtComponent.tsx new file mode 100644 index 0000000..9363be2 --- /dev/null +++ b/frontend/src/components/routes/ArtComponent.tsx @@ -0,0 +1,11 @@ +import { CategoryPage } from './CategoryPage' + +export function ArtComponent() { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/components/routes/CategoryPage.tsx b/frontend/src/components/routes/CategoryPage.tsx new file mode 100644 index 0000000..5ee462e --- /dev/null +++ b/frontend/src/components/routes/CategoryPage.tsx @@ -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 ( +
+
Вчитување...
+
+ ) + } + + if (articlesError) { + return ( +
+
Грешка при вчитување на статии
+
+ ) + } + + return ( +
+ {/* Category Header */} +
+

{categoryName}

+ {categoryDescription && ( +

{categoryDescription}

+ )} +
+ + {/* Hero Section with Pinned Live Blogs Sidebar */} + {heroArticle && ( +
+ {/* Hero Article - 2/3 width */} +
+
+ + {heroArticle.featuredImage ? ( +
+ {heroArticle.title} +
+
+ ) : ( +
+ + + + + + +
+ )} + + +
+ +

+ {heroArticle.title} +

+ {heroArticle.excerpt && ( +

+ {heroArticle.excerpt} +

+ )} + + +
+
+ + {new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + {heroArticle.views} прегледи +
+ + +
+
+
+
+ + {/* Pinned Live Blogs Sidebar - 1/3 width */} +
+ +
+
+ )} + + {/* Articles Grid */} +
+ {articles + .filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid + .map((article) => ( +
+ +

+ {article.title} +

+ {article.excerpt && ( +

+ {article.excerpt} +

+ )} + + +
+
+ + {new Date(article.createdAt).toLocaleDateString('mk-MK', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + {article.views} прегледи +
+ + +
+
+ ))} +
+ + {articles.length === 0 && ( +
+

+ Нема објавени статии во оваа категорија. Проверете подоцна! +

+
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/routes/ScienceComponent.tsx b/frontend/src/components/routes/ScienceComponent.tsx new file mode 100644 index 0000000..b242a9b --- /dev/null +++ b/frontend/src/components/routes/ScienceComponent.tsx @@ -0,0 +1,11 @@ +import { CategoryPage } from './CategoryPage' + +export function ScienceComponent() { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/components/routes/SportComponent.tsx b/frontend/src/components/routes/SportComponent.tsx new file mode 100644 index 0000000..531c570 --- /dev/null +++ b/frontend/src/components/routes/SportComponent.tsx @@ -0,0 +1,11 @@ +import { CategoryPage } from './CategoryPage' + +export function SportComponent() { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6156803..b36a87d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -407,6 +407,14 @@ export async function fetchHeroArticle(): Promise
{ return article || null; } +export async function fetchLatestArticles(limit = 12): Promise { + 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 { const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`); if (!response.ok) { diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 56ec215..2644475 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,6 +1,6 @@ import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router' import { ArticleTicker } from './components/ArticleTicker' -import { ArticlesComponent } from './components/routes/ArticlesComponent' +import { ArchiveComponent } from './components/routes/ArchiveComponent' import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent' import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' @@ -8,11 +8,15 @@ import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminCompone import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' 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 { ProtectedRoute } from './components/auth/ProtectedRoute' import { Header } from './components/layout/Header' import { HeroArticle } from './components/home/HeroArticle' import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar' +import { LatestArticlesGrid } from './components/home/LatestArticlesGrid' import './styles.css' const rootRoute = createRootRoute({ @@ -67,6 +71,11 @@ const indexRoute = createRoute({
+ {/* Latest Articles Grid - 4x3 */} +
+ +
+ {/* Brand Introduction */}
@@ -141,10 +150,10 @@ const indexRoute = createRoute({

- Read Articles + Прелистај архива @@ -168,10 +177,28 @@ const indexRoute = createRoute({ ), }) -const articlesRoute = createRoute({ +const archiveRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/articles', - component: ArticlesComponent, + path: '/archive', + 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({ @@ -308,7 +335,10 @@ const adminDashboardRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, - articlesRoute, + archiveRoute, + sportRoute, + artRoute, + scienceRoute, articleDetailRoute, liveBlogsRoute, liveBlogDetailRoute, diff --git a/todos.md b/todos.md index 9e2a4a4..4ce74aa 100644 --- a/todos.md +++ b/todos.md @@ -1,6 +1,7 @@ 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.