feat(blog): redesign post list cards

This commit is contained in:
2026-06-10 11:56:09 +03:30
parent 039158e0c4
commit 0d01933f9d
3 changed files with 135 additions and 45 deletions

View 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>
);
}

View File

@@ -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 DEFAULT_THUMB = "/placeholder.svg";
const pickFirstUrl = (...values: Array<string | null | undefined>) => const pickFirstUrl = (...values: Array<string | null | undefined>) =>
@@ -66,6 +84,22 @@ export const getEventSeoImageUrl = (event: Types.EventListItemSchema) =>
export const getThumbUrl = getEventCardImageUrl; 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 = ( export const getGalleryImagePreviewUrl = (
image: image:
| Types.EventGalleryItem | Types.EventGalleryItem

View File

@@ -1,12 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; 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 { Link, useLocation, useNavigate } from "@/lib/router";
import { api } from "@/lib/api"; 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 { 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 = { type BlogProps = {
initialPosts?: Types.PostListSchema[]; initialPosts?: Types.PostListSchema[];
@@ -60,59 +62,55 @@ export default function Blog({
}, [location.pathname, navigate, search]); }, [location.pathname, navigate, search]);
return ( return (
<div className="min-h-screen bg-background"> <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-8"> <div className="container mx-auto px-4 py-10">
<h1 className="text-4xl font-bold mb-8">وبلاگ</h1> <div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div className="text-right">
<div className="mb-8"> <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 <Input
type="text" type="text"
placeholder="جستجو در مقالات..." placeholder="جستجو در نوشته‌ها..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(event) => setSearch(event.target.value)}
className="max-w-md" className="max-w-md rounded-full bg-background/80 text-right shadow-sm backdrop-blur"
/> />
</div> </div>
{loading ? ( {loading ? (
<p className="text-center text-muted-foreground">در حال بارگذاری...</p> <p className="text-center text-muted-foreground">در حال بارگذاری...</p>
) : posts.length === 0 ? ( ) : 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) => ( {posts.map((post) => (
<Link key={post.id} to={blogPostPath(post.slug)}> <Link
<Card className="h-full hover:shadow-lg transition-shadow"> key={post.id}
{(post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image) ? ( to={blogPostPath(post.slug)}
<div className="aspect-video overflow-hidden rounded-t-lg bg-muted"> 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"
<img >
src={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image} <BlogThumbnail
alt={post.title} post={post}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]" imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
loading="lazy" className="aspect-[16/10] rounded-t-[2rem]"
/> />
</div> <article className="space-y-4 p-5 text-right">
) : null} <h2 className="line-clamp-2 text-xl font-bold leading-9 transition group-hover:text-primary">
<CardHeader> {post.title}
<CardTitle className="line-clamp-2">{post.title}</CardTitle> </h2>
<CardDescription> <p className="line-clamp-3 min-h-[5.25rem] text-sm leading-7 text-muted-foreground">
{post.category?.name && ( {post.excerpt || post.seo_description || "خلاصه‌ای برای این نوشته ثبت نشده است."}
<span className="text-primary ml-2">{post.category.name}</span> </p>
)} <time className="block text-xs font-medium text-primary/80" dateTime={post.published_at || post.created_at}>
{new Date(post.created_at).toLocaleDateString("fa-IR")} {formatJalaliDate(post.published_at || post.created_at)}
</CardDescription> </time>
</CardHeader> </article>
<CardContent>
{post.excerpt && (
<p className="text-muted-foreground line-clamp-3 mb-4">
{post.excerpt}
</p>
)}
<p className="text-sm">
نویسنده: {post.author.first_name} {post.author.last_name}
</p>
</CardContent>
</Card>
</Link> </Link>
))} ))}
</div> </div>