altricade_portofolio/src/routes/blog.tsx
Заид Омар Медхат | Zaid Omar Medhat fab8983d5c
Some checks failed
Deploy / deploy (push) Failing after 5m27s
init
2026-07-09 12:21:21 +05:00

294 lines
9.3 KiB
TypeScript

import { Link, Outlet, createFileRoute, useMatch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { ArrowRight, Calendar, Search, Tag } from 'lucide-react'
import { Input } from '@components/ui/input'
import { Badge } from '@components/ui/badge'
import { Skeleton } from '@components/ui/skeleton'
import { cn } from '@lib/utils'
import { useDebounce } from '@/hooks/use-debounce'
import { useArticleTags, useArticles } from '@/hooks/use-articles'
import { useAppSearchParams } from '@/lib/use-app-search-params'
export const Route = createFileRoute('/blog')({
component: BlogLayout,
})
function BlogLayout() {
const match = useMatch({ from: '/blog/$slug', shouldThrow: false })
if (match) {
return <Outlet />
}
return <BlogPage />
}
function BlogPage() {
const { getParam, setParam } = useAppSearchParams()
const { t } = useTranslation()
const [page, setPage] = useState(1)
const searchParam = getParam('search')
const tagParam = getParam('tag')
const [searchInput, setSearchInput] = useState(searchParam ?? '')
const [selectedTag, setSelectedTag] = useState<string | null>(tagParam)
const debouncedSearch = useDebounce(searchInput, 400)
const debouncedTag = useDebounce(selectedTag ?? '', 200)
useEffect(() => {
const normalizedParam = searchParam ?? ''
setSearchInput((current) =>
current === normalizedParam ? current : normalizedParam,
)
}, [searchParam])
useEffect(() => {
const normalizedParam = searchParam ?? ''
if (debouncedSearch === normalizedParam) {
return
}
setParam('search', debouncedSearch)
setPage(1)
}, [debouncedSearch, searchParam, setParam])
useEffect(() => {
setSelectedTag(tagParam)
}, [tagParam])
useEffect(() => {
const normalizedTag = tagParam ?? ''
if (debouncedTag === normalizedTag) {
return
}
if (debouncedTag) {
setParam('tag', debouncedTag)
} else {
setParam('tag', '')
}
setPage(1)
}, [debouncedTag, tagParam, setParam])
const { data, isLoading } = useArticles({
page,
limit: 9,
search: searchParam || undefined,
tag: tagParam || undefined,
})
const { data: tags } = useArticleTags()
const handleTagClick = (tag: string) => {
setSelectedTag((current) => (current === tag ? null : tag))
}
const totalPages = data ? Math.ceil(data.total / 9) : 0
return (
<main className="min-h-screen px-6 pt-24 pb-16">
<div className="mx-auto max-w-6xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">
{t('common.blog')}
</h1>
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{t(
'blog.description',
'Thoughts, tutorials, and insights about web development and technology.',
)}
</p>
</div>
<div className="mb-8 space-y-4">
<div className="relative max-w-md mx-auto">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t('blog.searchPlaceholder', 'Search articles...')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
{tags && tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{tags.map((tag) => (
<Badge
key={tag}
variant={selectedTag === tag ? 'default' : 'outline'}
className="cursor-pointer transition-colors"
onClick={() => handleTagClick(tag)}
>
<Tag className="mr-1 size-3" />
{tag}
</Badge>
))}
{selectedTag && (
<Badge
variant="secondary"
className="cursor-pointer"
onClick={() => setSelectedTag(null)}
>
{t('blog.clearFilter', 'Clear filter')}
</Badge>
)}
</div>
)}
</div>
{isLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
) : data?.articles.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground text-lg">
{searchParam || selectedTag
? t(
'blog.noResults',
'No articles found matching your criteria.',
)
: t(
'blog.noArticles',
'No articles published yet. Check back soon!',
)}
</p>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{data?.articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-12">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className={cn(
'px-4 py-2 rounded-lg border transition-colors',
page === 1
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-accent',
)}
>
{t('common.previous', 'Previous')}
</button>
<span className="text-sm text-muted-foreground px-4">
{page} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className={cn(
'px-4 py-2 rounded-lg border transition-colors',
page === totalPages
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-accent',
)}
>
{t('common.next', 'Next')}
</button>
</div>
)}
</>
)}
</div>
</main>
)
}
interface ArticleCardProps {
article: {
id: string
title: string
slug: string
preview: string
coverImage?: string
tags: Array<string>
createdAt: string
}
}
function ArticleCard({ article }: ArticleCardProps) {
return (
<Link
to="/blog/$slug"
params={{ slug: article.slug }}
className="group flex flex-col rounded-xl border bg-card overflow-hidden transition-all hover:shadow-lg hover:border-primary/50"
>
{article.coverImage ? (
<div className="aspect-video overflow-hidden">
<img
src={article.coverImage}
alt={article.title}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
) : (
<div className="aspect-video bg-muted flex items-center justify-center">
<span className="text-4xl font-bold text-muted-foreground/30">
{article.title.charAt(0)}
</span>
</div>
)}
<div className="flex flex-col flex-1 p-5">
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{article.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h2>
<p className="text-muted-foreground text-sm line-clamp-3 flex-1">
{article.preview}
</p>
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="flex items-center text-xs text-muted-foreground">
<Calendar className="mr-1 size-3" />
{new Date(article.createdAt).toLocaleDateString()}
</div>
<span className="text-sm font-medium text-primary flex items-center gap-1 group-hover:gap-2 transition-all">
Read more
<ArrowRight className="size-4" />
</span>
</div>
</div>
</Link>
)
}
function ArticleCardSkeleton() {
return (
<div className="flex flex-col rounded-xl border bg-card overflow-hidden">
<Skeleton className="aspect-video" />
<div className="p-5 space-y-3">
<div className="flex gap-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-12" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex justify-between pt-4 border-t">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</div>
)
}