feat: render CMS-driven public redesign
This commit is contained in:
39
src/components/ContentActionLink.tsx
Normal file
39
src/components/ContentActionLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
type ContentActionLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
||||
href: string;
|
||||
fallbackHref: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function isSafeInternalPath(value: string) {
|
||||
const hasControlCharacter = Array.from(value).some((character) => character.charCodeAt(0) < 0x20);
|
||||
return value.startsWith("/") && !value.startsWith("//") && !value.includes("\\") && !hasControlCharacter;
|
||||
}
|
||||
|
||||
function safeHref(value: string, fallbackHref: string) {
|
||||
const trimmed = value.trim();
|
||||
if (isSafeInternalPath(trimmed)) return trimmed;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "https:" && parsed.hostname && !parsed.username && !parsed.password) {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {
|
||||
// An invalid CMS link falls back to the known local destination.
|
||||
}
|
||||
return isSafeInternalPath(fallbackHref) ? fallbackHref : "/";
|
||||
}
|
||||
|
||||
export default function ContentActionLink({ href, fallbackHref, children, ...props }: ContentActionLinkProps) {
|
||||
const destination = safeHref(href, fallbackHref);
|
||||
const className = props.className;
|
||||
const shared = { ...props, className };
|
||||
|
||||
if (destination.startsWith("https://")) {
|
||||
return <a href={destination} target="_blank" rel="noopener noreferrer" {...shared}>{children}</a>;
|
||||
}
|
||||
|
||||
return <Link to={destination} {...shared}>{children}</Link>;
|
||||
}
|
||||
@@ -1,129 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { Instagram, Mail, MapPin, Phone, Send, Globe2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
|
||||
import type { SiteContent } from "@/lib/content-cms";
|
||||
|
||||
export default function Footer() {
|
||||
function hasRealContact(value: string) {
|
||||
return Boolean(value.trim()) && !/\.invalid(?:\/|$)/i.test(value) && !value.includes("0000000000");
|
||||
}
|
||||
|
||||
export default function Footer({ siteContent }: { siteContent: SiteContent }) {
|
||||
const year = new Date().getFullYear();
|
||||
const contacts = [
|
||||
hasRealContact(siteContent.contacts.email)
|
||||
? { label: "ایمیل", value: siteContent.contacts.email, href: `mailto:${siteContent.contacts.email}`, icon: Mail, dir: "ltr" as const }
|
||||
: null,
|
||||
hasRealContact(siteContent.contacts.phone)
|
||||
? { label: "تلفن", value: siteContent.contacts.phone, href: `tel:${siteContent.contacts.phone}`, icon: Phone, dir: "ltr" as const }
|
||||
: null,
|
||||
hasRealContact(siteContent.contacts.telegram)
|
||||
? { label: "تلگرام", value: "تلگرام انجمن", href: siteContent.contacts.telegram, icon: Send, dir: undefined }
|
||||
: null,
|
||||
hasRealContact(siteContent.contacts.instagram)
|
||||
? { label: "اینستاگرام", value: "اینستاگرام انجمن", href: siteContent.contacts.instagram, icon: Instagram, dir: undefined }
|
||||
: null,
|
||||
hasRealContact(siteContent.contacts.website)
|
||||
? { label: "وبسایت", value: siteContent.contacts.website, href: siteContent.contacts.website, icon: Globe2, dir: "ltr" as const }
|
||||
: null,
|
||||
].filter((contact) => contact !== null);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40"
|
||||
dir="rtl"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<footer className="bg-footer text-footer-foreground" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10 md:py-12">
|
||||
<div className="grid gap-9 sm:grid-cols-2 lg:grid-cols-[1.5fr_0.8fr_1fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
|
||||
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-7">
|
||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
|
||||
</p>
|
||||
<Link to="/" className="inline-flex items-center gap-3 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/10 p-1.5">
|
||||
<Image
|
||||
src={siteContent.logo?.url || "/favicon.ico"}
|
||||
alt={siteContent.logo?.alt || siteContent.short_name || "لوگوی انجمن"}
|
||||
width={44}
|
||||
height={44}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-base font-bold leading-7">{siteContent.official_name}</span>
|
||||
</Link>
|
||||
{siteContent.description ? (
|
||||
<p className="max-w-xl text-sm leading-7 text-footer-muted">
|
||||
{siteContent.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||
خانه
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/events" className="text-muted-foreground hover:text-foreground">
|
||||
رویدادها
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
|
||||
بلاگ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-muted-foreground hover:text-foreground">
|
||||
دربارهی انجمن
|
||||
</Link>
|
||||
</li>
|
||||
<h2 className="mb-4 text-sm font-bold">دسترسی سریع</h2>
|
||||
<ul className="space-y-3 text-sm text-footer-muted">
|
||||
<li><Link className="transition-colors hover:text-footer-foreground" to="/">{siteContent.navigation.home}</Link></li>
|
||||
<li><Link className="transition-colors hover:text-footer-foreground" to="/about">{siteContent.navigation.about}</Link></li>
|
||||
<li><Link className="transition-colors hover:text-footer-foreground" to="/events">{siteContent.navigation.events}</Link></li>
|
||||
<li><Link className="transition-colors hover:text-footer-foreground" to="/blog">{siteContent.navigation.blog}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>ایمیل: info@east-guilan-ce.ir</li>
|
||||
<li>آدرس: دانشگاه گیلان، دانشکدهی فنی و مهندسی شرق گیلان</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<a
|
||||
href="https://Instagram.com/guilance.ir"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
|
||||
<Instagram className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/guilance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/in/amiirkhl/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
aria-label="ایکس (توییتر)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="justify-self-end">
|
||||
<a
|
||||
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
referrerPolicy="origin"
|
||||
>
|
||||
<img
|
||||
src="/enamad.png"
|
||||
width="125"
|
||||
alt="نماد اعتماد الکترونیکی"
|
||||
referrerPolicy="origin"
|
||||
style={{ cursor: "pointer" }}
|
||||
data-code="m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
||||
/>
|
||||
</a>
|
||||
<h2 className="mb-4 text-sm font-bold">ارتباط با انجمن</h2>
|
||||
{siteContent.address ? (
|
||||
<p className="mb-3 flex items-start gap-2 text-sm leading-6 text-footer-muted">
|
||||
<MapPin aria-hidden="true" className="mt-1 h-4 w-4 shrink-0" />
|
||||
<span>{siteContent.address}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{contacts.length ? (
|
||||
<ul className="space-y-3 text-sm text-footer-muted">
|
||||
{contacts.map(({ label, value, href, icon: Icon, dir }) => (
|
||||
<li key={label}>
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href.startsWith("http") ? "noreferrer" : undefined}
|
||||
className="inline-flex min-h-11 items-center gap-2 transition-colors hover:text-footer-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
<span dir={dir}>{value}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-8 h-px w-full bg-border" />
|
||||
|
||||
<div className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
|
||||
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
|
||||
<div className="my-8 h-px bg-footer-foreground/15" />
|
||||
{siteContent.enamad_image ? (
|
||||
<div className="mb-7 flex justify-start">
|
||||
<div className="rounded-xl bg-white p-2 shadow-sm">
|
||||
{siteContent.enamad_url ? (
|
||||
<a href={siteContent.enamad_url} target="_blank" rel="noopener noreferrer" aria-label="مشاهده گواهی اعتماد الکترونیکی">
|
||||
<Image
|
||||
src={siteContent.enamad_image.url}
|
||||
alt={siteContent.enamad_image.alt || "نشان اعتماد الکترونیکی"}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-24 w-24 object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Image
|
||||
src={siteContent.enamad_image.url}
|
||||
alt={siteContent.enamad_image.alt || "نشان اعتماد الکترونیکی"}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-24 w-24 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 text-xs text-footer-muted sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>© {year} {siteContent.short_name || siteContent.official_name}</span>
|
||||
{siteContent.footer_copy ? <span>{siteContent.footer_copy}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SiteContent } from "@/lib/content-cms";
|
||||
|
||||
type NavItem = {
|
||||
key: string;
|
||||
@@ -22,7 +23,7 @@ const isProfileOrAuthPath = (pathname: string) =>
|
||||
pathname.startsWith("/auth/") ||
|
||||
pathname.startsWith("/reset-password");
|
||||
|
||||
export default function MobileBottomNav() {
|
||||
export default function MobileBottomNav({ siteContent }: { siteContent: SiteContent }) {
|
||||
const pathname = usePathname() || "/";
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
@@ -33,21 +34,21 @@ export default function MobileBottomNav() {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
key: "home",
|
||||
label: "خانه",
|
||||
label: siteContent.navigation.home,
|
||||
href: "/",
|
||||
icon: Home,
|
||||
matches: (current) => current === "/",
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "رویدادها",
|
||||
label: siteContent.navigation.events,
|
||||
href: "/events",
|
||||
icon: CalendarDays,
|
||||
matches: (current) => current === "/events" || current.startsWith("/events/"),
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
label: "بلاگ",
|
||||
label: siteContent.navigation.blog,
|
||||
href: "/blog",
|
||||
icon: Newspaper,
|
||||
matches: (current) => current === "/blog" || current.startsWith("/blog/"),
|
||||
@@ -67,8 +68,8 @@ export default function MobileBottomNav() {
|
||||
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
||||
>
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
|
||||
aria-label="ناوبری اصلی موبایل"
|
||||
className="mx-auto flex w-full max-w-md items-center justify-between rounded-2xl border border-border bg-background/95 px-1.5 py-1.5 shadow-lg backdrop-blur-xl"
|
||||
dir="rtl"
|
||||
>
|
||||
{items.map((item) => {
|
||||
@@ -80,15 +81,15 @@ export default function MobileBottomNav() {
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[11px] font-medium transition-all",
|
||||
"flex min-h-11 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1.5 text-[10px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
|
||||
? "bg-secondary text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
|
||||
<span>{item.label}</span>
|
||||
<Icon aria-hidden="true" className="h-4 w-4" />
|
||||
<span className="max-w-full truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function ModeToggle({ className }: { className?: string }) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
|
||||
"rounded-full border-0 bg-transparent text-foreground shadow-none transition hover:bg-secondary hover:text-secondary-foreground hover:shadow-sm dark:hover:bg-secondary dark:hover:text-secondary-foreground",
|
||||
className,
|
||||
)}
|
||||
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
|
||||
import { Link, NavLink } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -18,16 +19,17 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SiteContent } from "@/lib/content-cms";
|
||||
|
||||
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||
"rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
? "bg-secondary text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -115,34 +117,42 @@ function ProfileAvatarMenu() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
export default function Navbar({ siteContent }: { siteContent: SiteContent }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
aria-label="ناوبری اصلی"
|
||||
className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85"
|
||||
dir="rtl"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||
<div className="hidden rounded-2xl sm:flex">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
|
||||
<div className="container mx-auto px-4 py-2">
|
||||
<div className="flex min-h-14 items-center justify-between gap-4">
|
||||
<Link to="/" className="flex min-w-0 items-center gap-3 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-brand-wash p-1.5">
|
||||
<Image
|
||||
src={siteContent.logo?.url || "/favicon.ico"}
|
||||
alt={siteContent.logo?.alt || siteContent.short_name || "لوگوی انجمن"}
|
||||
width={44}
|
||||
height={44}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
انجمن علمی مهندسی کامپیوتر
|
||||
{siteContent.short_name || siteContent.official_name}
|
||||
</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">
|
||||
دانشکدهی فنی و مهندسی شرق گیلان
|
||||
{siteContent.faculty_subline}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<NavItem to="/">خانه</NavItem>
|
||||
<NavItem to="/blog">بلاگ</NavItem>
|
||||
<NavItem to="/events">رویدادها</NavItem>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<NavItem to="/">{siteContent.navigation.home}</NavItem>
|
||||
<NavItem to="/events">{siteContent.navigation.events}</NavItem>
|
||||
<NavItem to="/blog">{siteContent.navigation.blog}</NavItem>
|
||||
<ModeToggle />
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ProfileAvatarMenu />
|
||||
|
||||
122
src/components/home/FeaturedEvents.tsx
Normal file
122
src/components/home/FeaturedEvents.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import Image from "next/image";
|
||||
import { ArrowUpLeft, CalendarDays, MapPin } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Link } from "@/lib/router";
|
||||
import ContentActionLink from "@/components/ContentActionLink";
|
||||
import type { HomeContentPage } from "@/lib/content-cms";
|
||||
import type { EventListItemSchema } from "@/lib/types";
|
||||
import { formatJalali, getEventCardImageUrl } from "@/lib/utils";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
|
||||
type FeaturedEventsProps = {
|
||||
content: HomeContentPage;
|
||||
events: EventListItemSchema[];
|
||||
isArchive: boolean;
|
||||
};
|
||||
|
||||
export default function FeaturedEvents({ content, events, isArchive }: FeaturedEventsProps) {
|
||||
return (
|
||||
<section className="py-14 md:py-20" aria-labelledby="home-events-title">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-7 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-9">
|
||||
<div className="max-w-2xl">
|
||||
<h2 id="home-events-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
|
||||
{content.featured_events_heading}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-7 text-muted-foreground md:text-base">
|
||||
{content.featured_events_intro}
|
||||
</p>
|
||||
</div>
|
||||
<ContentActionLink
|
||||
href={content.featured_events_url}
|
||||
fallbackHref="/events"
|
||||
className="inline-flex min-h-11 w-fit items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{content.featured_events_link_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</ContentActionLink>
|
||||
</div>
|
||||
|
||||
{events.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{events.map((event) => {
|
||||
const candidateImage = getEventCardImageUrl(event);
|
||||
const hasImage = candidateImage !== "/placeholder.svg";
|
||||
const image = hasImage ? toAbsoluteUrl(candidateImage, apiBaseUrl) : undefined;
|
||||
|
||||
return (
|
||||
<Card key={event.id} className="overflow-hidden rounded-2xl border-border/80 shadow-none transition-colors hover:border-primary/40">
|
||||
<div className="relative aspect-[16/10] bg-brand-wash">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={`پوستر ${event.title}`}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
unoptimized
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-brand-deep" aria-hidden="true">
|
||||
<CalendarDays className="h-10 w-10" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
{isArchive ? (
|
||||
<Badge variant="secondary" className="absolute right-3 top-3 border border-border/70 bg-background/95 text-foreground">
|
||||
از آرشیو
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<CardHeader className="space-y-2 p-4 pb-2">
|
||||
<CardTitle className="text-lg leading-7">
|
||||
<Link to={`/events/${encodeURIComponent(event.slug)}`} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{event.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-1">
|
||||
<p className="line-clamp-2 min-h-12 text-sm leading-6 text-muted-foreground">
|
||||
{event.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/70 pt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarDays aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
|
||||
<time dateTime={event.start_time}>{formatJalali(event.start_time, false)}</time>
|
||||
</span>
|
||||
{(event.location || event.address || event.event_type_label) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{event.location || event.address || event.event_type_label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="px-4 pb-4 pt-0">
|
||||
<Link
|
||||
to={`/events/${encodeURIComponent(event.slug)}`}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{content.featured_event_action_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
در حال حاضر رویدادی برای نمایش نیست.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
145
src/components/home/FeaturedPosts.tsx
Normal file
145
src/components/home/FeaturedPosts.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import Image from "next/image";
|
||||
import { ArrowUpLeft, BookOpen, Newspaper } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Link } from "@/lib/router";
|
||||
import ContentActionLink from "@/components/ContentActionLink";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import type { HomeContentPage, SiteContent } from "@/lib/content-cms";
|
||||
import type { PostListSchema } from "@/lib/types";
|
||||
import { formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
|
||||
type FeaturedPostsProps = {
|
||||
content: HomeContentPage;
|
||||
siteContent: SiteContent;
|
||||
posts: PostListSchema[];
|
||||
};
|
||||
|
||||
function PostCover({ post, brandName, compact = false }: {
|
||||
post: PostListSchema;
|
||||
brandName: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const imageUrl = getBlogCardImageUrl(post);
|
||||
const image = imageUrl ? toAbsoluteUrl(imageUrl, apiBaseUrl) : undefined;
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-primary text-primary-foreground ${compact ? "h-full min-h-24 w-24 shrink-0" : "aspect-[16/10] md:aspect-auto md:min-h-64"}`}>
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={post.title}
|
||||
fill
|
||||
sizes={compact ? "96px" : "(max-width: 768px) 100vw, 45vw"}
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-5 text-center" aria-hidden="true">
|
||||
{compact ? <BookOpen className="h-7 w-7 text-primary-foreground" strokeWidth={1.5} /> : <Newspaper className="h-10 w-10 text-primary-foreground" strokeWidth={1.5} />}
|
||||
<span className="text-xs font-semibold text-primary-foreground/85">{brandName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostDescription({ post }: { post: PostListSchema }) {
|
||||
return post.excerpt ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">{post.excerpt}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default function FeaturedPosts({ content, siteContent, posts }: FeaturedPostsProps) {
|
||||
const [lead, ...rest] = posts;
|
||||
|
||||
return (
|
||||
<section className="border-y border-border/70 bg-brand-wash/55 py-14 md:py-20" aria-labelledby="home-posts-title">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-7 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-9">
|
||||
<div className="max-w-2xl">
|
||||
<h2 id="home-posts-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
|
||||
{content.featured_posts_heading}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-7 text-muted-foreground md:text-base">
|
||||
{content.featured_posts_intro}
|
||||
</p>
|
||||
</div>
|
||||
<ContentActionLink
|
||||
href={content.featured_posts_url}
|
||||
fallbackHref="/blog"
|
||||
className="inline-flex min-h-11 w-fit items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{content.featured_posts_link_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</ContentActionLink>
|
||||
</div>
|
||||
|
||||
{lead ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card className="overflow-hidden rounded-2xl border-border/80 shadow-none md:grid md:grid-cols-[0.9fr_1.1fr]">
|
||||
<PostCover post={lead} brandName={siteContent.short_name} />
|
||||
<div className="flex flex-col justify-center">
|
||||
<CardContent className="p-5 md:p-7">
|
||||
{lead.category?.name ? <Badge variant="secondary" className="mb-3">{lead.category.name}</Badge> : null}
|
||||
<h3 className="text-xl font-bold leading-8 md:text-2xl">
|
||||
<Link to={blogPostPath(lead.slug)} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{lead.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<PostDescription post={lead} />
|
||||
{lead.published_at ? (
|
||||
<time className="mt-4 block text-xs text-muted-foreground" dateTime={lead.published_at}>
|
||||
{formatJalaliDate(lead.published_at)}
|
||||
</time>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardFooter className="px-5 pb-5 pt-0 md:px-7 md:pb-7">
|
||||
<Link to={blogPostPath(lead.slug)} className="inline-flex min-h-11 items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{content.featured_post_action_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{rest.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{rest.map((post) => (
|
||||
<Card key={post.id} className="flex overflow-hidden rounded-2xl border-border/80 shadow-none sm:min-h-36 lg:min-h-0">
|
||||
<PostCover post={post} brandName={siteContent.short_name} compact />
|
||||
<CardContent className="min-w-0 p-4">
|
||||
{post.category?.name ? <Badge variant="outline" className="mb-2">{post.category.name}</Badge> : null}
|
||||
<h3 className="line-clamp-2 font-bold leading-7">
|
||||
<Link to={blogPostPath(post.slug)} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<PostDescription post={post} />
|
||||
<Link
|
||||
to={blogPostPath(post.slug)}
|
||||
className="mt-2 inline-flex min-h-10 items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{content.featured_post_action_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
هنوز نوشتهای برای نمایش نیست.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user