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 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user