feat(blog): redesign post list cards
This commit is contained in:
58
src/components/BlogThumbnail.tsx
Normal file
58
src/components/BlogThumbnail.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BlogThumbnailProps = {
|
||||
post: Pick<Types.PostListSchema, "title" | "category" | "absolute_featured_image_thumbnail_url" | "absolute_featured_image_preview_url" | "absolute_featured_image_url" | "featured_image">;
|
||||
imageUrl?: string | null;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
function initials(title: string) {
|
||||
return title
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("");
|
||||
}
|
||||
|
||||
export default function BlogThumbnail({
|
||||
post,
|
||||
imageUrl,
|
||||
className,
|
||||
imageClassName,
|
||||
priority = false,
|
||||
}: BlogThumbnailProps) {
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<div className={cn("overflow-hidden bg-muted", className)}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={post.title}
|
||||
className={cn("h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]", imageClassName)}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden bg-[radial-gradient(circle_at_20%_20%,rgba(34,197,94,0.28),transparent_32%),linear-gradient(135deg,#0f3d2e,#163f59_52%,#111827)] text-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(45deg,rgba(255,255,255,0.08)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.08)_50%,rgba(255,255,255,0.08)_75%,transparent_75%,transparent)] bg-[length:28px_28px] opacity-25" />
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<span className="rounded-full border border-white/25 bg-white/15 px-3 py-1 text-xs backdrop-blur">
|
||||
{post.category?.name || "بلاگ"}
|
||||
</span>
|
||||
<span className="text-5xl font-black tracking-tight">{initials(post.title) || "گـ"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,24 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatJalaliDate(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const locale = Intl.DateTimeFormat.supportedLocalesOf(['fa-IR-u-ca-persian']).length > 0
|
||||
? 'fa-IR-u-ca-persian'
|
||||
: 'fa-IR';
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_THUMB = "/placeholder.svg";
|
||||
|
||||
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
||||
@@ -66,6 +84,22 @@ export const getEventSeoImageUrl = (event: Types.EventListItemSchema) =>
|
||||
|
||||
export const getThumbUrl = getEventCardImageUrl;
|
||||
|
||||
export const getBlogCardImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getBlogHeroImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getGalleryImagePreviewUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
|
||||
|
||||
type BlogProps = {
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
@@ -60,59 +62,55 @@ export default function Blog({
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">وبلاگ</h1>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||
<div className="text-right">
|
||||
<p className="mb-2 text-sm font-medium text-primary">خواندنیهای انجمن</p>
|
||||
<h1 className="text-4xl font-black tracking-tight">بلاگ</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||
نوشتههای آموزشی، تجربههای دانشجویی و یادداشتهای تخصصی اعضای انجمن علمی.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در مقالات..."
|
||||
placeholder="جستجو در نوشتهها..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="max-w-md rounded-full bg-background/80 text-right shadow-sm backdrop-blur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">مقالهای یافت نشد</p>
|
||||
<p className="rounded-3xl border border-dashed bg-background/70 p-10 text-center text-muted-foreground">
|
||||
نوشتهای پیدا نشد.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.id} to={blogPostPath(post.slug)}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow">
|
||||
{(post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image) ? (
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg bg-muted">
|
||||
<img
|
||||
src={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
<Link
|
||||
key={post.id}
|
||||
to={blogPostPath(post.slug)}
|
||||
className="group overflow-hidden rounded-[2rem] border border-border/70 bg-card/85 shadow-sm transition duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-primary/10"
|
||||
>
|
||||
<BlogThumbnail
|
||||
post={post}
|
||||
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
||||
className="aspect-[16/10] rounded-t-[2rem]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2">{post.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{post.category?.name && (
|
||||
<span className="text-primary ml-2">{post.category.name}</span>
|
||||
)}
|
||||
{new Date(post.created_at).toLocaleDateString("fa-IR")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{post.excerpt && (
|
||||
<p className="text-muted-foreground line-clamp-3 mb-4">
|
||||
{post.excerpt}
|
||||
<article className="space-y-4 p-5 text-right">
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-9 transition group-hover:text-primary">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="line-clamp-3 min-h-[5.25rem] text-sm leading-7 text-muted-foreground">
|
||||
{post.excerpt || post.seo_description || "خلاصهای برای این نوشته ثبت نشده است."}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
نویسنده: {post.author.first_name} {post.author.last_name}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<time className="block text-xs font-medium text-primary/80" dateTime={post.published_at || post.created_at}>
|
||||
{formatJalaliDate(post.published_at || post.created_at)}
|
||||
</time>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user