category field added
implemented ctegory logic, ditional pages added
This commit is contained in:
1 parent
add12b2fbf
commit
46eb41aaa5
16 files changed
+598
-21
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -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' }),
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function ArtComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="art"
|
||||
categoryName="Уметност"
|
||||
categoryDescription="Уметност, култура и забава"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
|
||||
|
||||
interface CategoryPageProps {
|
||||
categorySlug: string
|
||||
categoryName: string
|
||||
categoryDescription?: string
|
||||
}
|
||||
|
||||
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
|
||||
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
|
||||
queryKey: ['category-articles', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
|
||||
})
|
||||
|
||||
const { data: heroData, isLoading: heroLoading } = useQuery({
|
||||
queryKey: ['category-hero', categorySlug],
|
||||
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
|
||||
enabled: !!categorySlug,
|
||||
})
|
||||
|
||||
const heroArticle = heroData?.data?.[0]
|
||||
const articles = articlesData?.data || []
|
||||
|
||||
if (articlesLoading || heroLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Вчитување...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (articlesError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Category Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{categoryName}</h1>
|
||||
{categoryDescription && (
|
||||
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero Section with Pinned Live Blogs Sidebar */}
|
||||
{heroArticle && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Hero Article - 2/3 width */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
{heroArticle.featuredImage ? (
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<img
|
||||
src={heroArticle.featuredImage}
|
||||
alt={heroArticle.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="p-6">
|
||||
<Link to={`/articles/${heroArticle.id}`} className="block">
|
||||
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
|
||||
{heroArticle.title}
|
||||
</h2>
|
||||
{heroArticle.excerpt && (
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3">
|
||||
{heroArticle.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{heroArticle.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={heroArticle.id}
|
||||
title={heroArticle.title}
|
||||
url={`${window.location.origin}/articles/${heroArticle.id}`}
|
||||
excerpt={heroArticle.excerpt}
|
||||
image={heroArticle.featuredImage}
|
||||
tags={heroArticle.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned Live Blogs Sidebar - 1/3 width */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{articles
|
||||
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
|
||||
.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
url={`${window.location.origin}/articles/${article.id}`}
|
||||
excerpt={article.excerpt}
|
||||
image={article.featuredImage}
|
||||
tags={article.tags}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{articles.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Нема објавени статии во оваа категорија. Проверете подоцна!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function ScienceComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="science"
|
||||
categoryName="Наука"
|
||||
categoryDescription="Научни откритија и технологија"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CategoryPage } from './CategoryPage'
|
||||
|
||||
export function SportComponent() {
|
||||
return (
|
||||
<CategoryPage
|
||||
categorySlug="sport"
|
||||
categoryName="Спорт"
|
||||
categoryDescription="Спортски вести и анализи"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -407,6 +407,14 @@ export async function fetchHeroArticle(): Promise<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[]> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
if (!response.ok) {
|
||||
|
||||
+37
-7
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Articles Grid - 4x3 */}
|
||||
<div className="mb-12">
|
||||
<LatestArticlesGrid />
|
||||
</div>
|
||||
|
||||
{/* Brand Introduction */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
@@ -141,10 +150,10 @@ const indexRoute = createRoute({
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<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"
|
||||
>
|
||||
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" />
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user