294 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|