feat: render CMS-driven public redesign
This commit is contained in:
@@ -1,65 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import AboutUs from "@/views/AboutUs";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
const title = "درباره ما | انجمن علمی کامپیوتر شرق گیلان";
|
||||
const description =
|
||||
"آشنایی با تاریخچه، مأموریتها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راههای مشارکت دانشجویان در برنامههای انجمن.";
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
keywords: [
|
||||
"انجمن علمی کامپیوتر شرق گیلان",
|
||||
"دانشگاه گیلان",
|
||||
"انجمن علمی دانشجویی",
|
||||
"رویدادهای فناوری",
|
||||
],
|
||||
alternates: { canonical: "/about" },
|
||||
openGraph: {
|
||||
title,
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [content, siteContent] = await Promise.all([
|
||||
getCmsPageContent("about"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const title = content.seo.title || content.title || siteContent.official_name;
|
||||
const description = content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: "/about" },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `${siteUrl}/about`,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const [content, siteContent] = await Promise.all([
|
||||
getCmsPageContent("about"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AboutPage",
|
||||
name: content.seo.title || content.title,
|
||||
description: content.seo.description || content.intro,
|
||||
url: `${siteUrl}/about`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
type: "website",
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
},
|
||||
};
|
||||
mainEntity: {
|
||||
"@type": "Organization",
|
||||
name: siteContent.official_name,
|
||||
url: siteUrl,
|
||||
logo: siteContent.logo?.url || `${siteUrl}/favicon.ico`,
|
||||
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
|
||||
},
|
||||
};
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AboutPage",
|
||||
name: title,
|
||||
description,
|
||||
url: `${siteUrl}/about`,
|
||||
mainEntity: {
|
||||
"@type": "Organization",
|
||||
name: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/favicon.ico`,
|
||||
sameAs: [
|
||||
"https://instagram.com/guilance.ir",
|
||||
"https://t.me/guilance",
|
||||
"https://t.me/guilancea",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<AboutUs />
|
||||
<AboutUs content={content} siteContent={siteContent} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Blog from "@/views/Blog";
|
||||
import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -21,13 +22,16 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const resolved = await searchParams;
|
||||
const search = firstString(resolved.search).trim();
|
||||
const title = search ? `جستوجوی وبلاگ: ${search}` : "وبلاگ";
|
||||
const description = search
|
||||
? `نتایج جستوجوی مقالات برای ${search} در وبلاگ انجمن علمی کامپیوتر شرق گیلان.`
|
||||
: "مقالات، نوشتهها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.";
|
||||
const [content, siteContent] = await Promise.all([
|
||||
getCmsPageContent("blog"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const title = search ? `جستوجوی وبلاگ: ${search}` : content.seo.title || content.title;
|
||||
const description = search ? `نتایج جستوجوی مقالات برای ${search}.` : content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title,
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: search ? `/blog?search=${encodeURIComponent(search)}` : "/blog" },
|
||||
robots: search ? { index: false, follow: true } : undefined,
|
||||
@@ -35,9 +39,9 @@ export async function generateMetadata({
|
||||
title,
|
||||
description,
|
||||
url: search ? `${siteUrl}/blog?search=${encodeURIComponent(search)}` : `${siteUrl}/blog`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
};
|
||||
@@ -53,14 +57,16 @@ export default async function BlogPage({
|
||||
const category = firstString(resolved.category).trim();
|
||||
const tags = stringList(resolved.tag);
|
||||
const authors = stringList(resolved.author);
|
||||
const [posts, banners, filters] = await Promise.all([
|
||||
const [posts, banners, filters, content] = await Promise.all([
|
||||
getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }),
|
||||
getBlogBanners().catch(() => []),
|
||||
getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })),
|
||||
getCmsPageContent("blog"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Blog
|
||||
content={content}
|
||||
initialPosts={posts}
|
||||
initialSearch={search}
|
||||
initialCategory={category}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Events from "@/views/Events";
|
||||
import { getPublicEvents } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -16,13 +17,16 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const resolved = await searchParams;
|
||||
const search = firstString(resolved.search).trim();
|
||||
const title = search ? `جستوجوی رویدادها: ${search}` : "رویدادها";
|
||||
const description = search
|
||||
? `نتایج جستوجوی رویدادها برای ${search} در انجمن علمی کامپیوتر شرق گیلان.`
|
||||
: "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاهها، مسابقات و برنامههای آموزشی.";
|
||||
const [content, siteContent] = await Promise.all([
|
||||
getCmsPageContent("events"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const title = search ? `جستوجوی رویدادها: ${search}` : content.seo.title || content.title;
|
||||
const description = search ? `نتایج جستوجوی رویدادها برای ${search}.` : content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title,
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: search ? `/events?search=${encodeURIComponent(search)}` : "/events" },
|
||||
robots: search ? { index: false, follow: true } : undefined,
|
||||
@@ -30,9 +34,9 @@ export async function generateMetadata({
|
||||
title,
|
||||
description,
|
||||
url: search ? `${siteUrl}/events?search=${encodeURIComponent(search)}` : `${siteUrl}/events`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
};
|
||||
@@ -45,7 +49,11 @@ export default async function EventsPage({
|
||||
}) {
|
||||
const resolved = await searchParams;
|
||||
const search = firstString(resolved.search).trim();
|
||||
const events = await getPublicEvents({ search: search || undefined });
|
||||
const [events, content, siteContent] = await Promise.all([
|
||||
getPublicEvents({ search: search || undefined }),
|
||||
getCmsPageContent("events"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
|
||||
return <Events initialEvents={events} initialSearch={search} />;
|
||||
return <Events content={content} siteName={siteContent.official_name} initialEvents={events} initialSearch={search} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import Providers from "@/components/providers";
|
||||
import RouteProgress from "@/components/RouteProgress";
|
||||
import { getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import "../index.css";
|
||||
|
||||
@@ -17,21 +18,35 @@ const vazirmatn = localFont({
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
template: "%s | انجمن علمی کامپیوتر شرق گیلان",
|
||||
},
|
||||
description:
|
||||
"انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاهها و محتوای آموزشی برای دانشجویان و علاقهمندان فناوری.",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const siteContent = await getCmsSiteContent();
|
||||
const title = siteContent.seo.title || siteContent.official_name;
|
||||
const description = siteContent.seo.description || siteContent.description;
|
||||
const image = siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
export default function RootLayout({
|
||||
return {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s | ${siteContent.short_name || siteContent.official_name}`,
|
||||
},
|
||||
description,
|
||||
openGraph: {
|
||||
siteName: siteContent.official_name,
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const siteContent = await getCmsSiteContent();
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<body className={`${vazirmatn.variable} font-sans antialiased`}>
|
||||
@@ -39,12 +54,12 @@ export default function RootLayout({
|
||||
<Suspense fallback={null}>
|
||||
<RouteProgress />
|
||||
</Suspense>
|
||||
<Navbar />
|
||||
<Navbar siteContent={siteContent} />
|
||||
<div className="min-h-screen pb-28 md:pb-0">
|
||||
{children}
|
||||
<Footer />
|
||||
<Footer siteContent={siteContent} />
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
<MobileBottomNav siteContent={siteContent} />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
102
src/app/page.tsx
102
src/app/page.tsx
@@ -1,56 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
import Home from "@/views/Home";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
|
||||
import { selectHomeEvents, selectHomePosts } from "@/lib/home-content";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
const title = "انجمن علمی کامپیوتر دانشگاه گیلان";
|
||||
const description =
|
||||
"با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.";
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
title,
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [siteContent, content] = await Promise.all([
|
||||
getCmsSiteContent(),
|
||||
getCmsPageContent("home"),
|
||||
]);
|
||||
const title = content.seo.title || content.hero_heading || siteContent.official_name;
|
||||
const description = content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: siteUrl,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const [siteContent, content, eventResult, postResult] = await Promise.all([
|
||||
getCmsSiteContent(),
|
||||
getCmsPageContent("home"),
|
||||
getPublicEvents({ limit: 30 }).catch(() => []),
|
||||
getPublicPosts({ limit: 30 }).catch(() => []),
|
||||
]);
|
||||
const selectedEvents = selectHomeEvents(eventResult);
|
||||
const posts = selectHomePosts(postResult);
|
||||
const title = content.seo.title || content.hero_heading || siteContent.official_name;
|
||||
const description = content.seo.description || content.intro;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: siteContent.official_name,
|
||||
url: siteUrl,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
type: "website",
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
|
||||
description,
|
||||
images: [`${siteUrl}/favicon.ico`],
|
||||
},
|
||||
};
|
||||
logo: siteContent.logo?.url || `${siteUrl}/favicon.ico`,
|
||||
contactPoint: siteContent.contacts.email
|
||||
? {
|
||||
"@type": "ContactPoint",
|
||||
email: siteContent.contacts.email,
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: title,
|
||||
url: siteUrl,
|
||||
sameAs: [`${siteUrl}/blog`, `${siteUrl}/events`],
|
||||
description,
|
||||
logo: `${siteUrl}/favicon.ico`,
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "admin@east-guilan-ce.ir",
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<Home
|
||||
content={content}
|
||||
siteContent={siteContent}
|
||||
events={selectedEvents.events}
|
||||
posts={posts}
|
||||
isEventArchive={selectedEvents.isArchive}
|
||||
/>
|
||||
<Home />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
110
src/index.css
110
src/index.css
@@ -9,32 +9,41 @@ All colors MUST be HSL.
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 150 2% 20%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 150 2% 20%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover-foreground: 150 2% 20%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary: 190 47% 29%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 170 29% 96%;
|
||||
--secondary-foreground: 190 51% 23%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--muted: 170 29% 96%;
|
||||
--muted-foreground: 186 13% 37%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 48 88% 78%;
|
||||
--accent-foreground: 150 2% 20%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--border: 174 18% 87%;
|
||||
--input: 174 18% 87%;
|
||||
--ring: 190 47% 29%;
|
||||
|
||||
--brand-teal: 190 47% 29%;
|
||||
--brand-deep: 190 51% 23%;
|
||||
--brand-gold: 48 88% 78%;
|
||||
--brand-wash: 170 29% 96%;
|
||||
--brand-charcoal: 150 2% 20%;
|
||||
--footer-background: 150 2% 20%;
|
||||
--footer-foreground: 0 0% 100%;
|
||||
--footer-muted: 172 13% 82%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
@@ -57,33 +66,43 @@ All colors MUST be HSL.
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
color-scheme: dark;
|
||||
--background: 194 35% 12%;
|
||||
--foreground: 165 20% 95%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--card: 190 33% 17%;
|
||||
--card-foreground: 165 20% 95%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--popover: 190 33% 17%;
|
||||
--popover-foreground: 165 20% 95%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 178 38% 73%;
|
||||
--primary-foreground: 194 35% 12%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--secondary: 190 30% 22%;
|
||||
--secondary-foreground: 165 20% 95%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--muted: 190 33% 17%;
|
||||
--muted-foreground: 184 12% 69%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--accent: 48 88% 78%;
|
||||
--accent-foreground: 150 2% 20%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--border: 190 24% 27%;
|
||||
--input: 190 24% 27%;
|
||||
--ring: 178 38% 73%;
|
||||
|
||||
--brand-teal: 178 38% 73%;
|
||||
--brand-deep: 178 38% 73%;
|
||||
--brand-gold: 48 88% 78%;
|
||||
--brand-wash: 190 30% 22%;
|
||||
--brand-charcoal: 165 20% 95%;
|
||||
--footer-background: 194 35% 12%;
|
||||
--footer-foreground: 165 20% 95%;
|
||||
--footer-muted: 184 12% 69%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
@@ -99,6 +118,7 @@ All colors MUST be HSL.
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -107,5 +127,33 @@ All colors MUST be HSL.
|
||||
|
||||
body {
|
||||
@apply bg-background font-sans text-foreground;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark a.home-hero-secondary:hover,
|
||||
html.dark a.home-hero-secondary:focus-visible {
|
||||
background-color: hsl(var(--secondary)) !important;
|
||||
border-color: hsl(var(--ring)) !important;
|
||||
color: hsl(var(--secondary-foreground)) !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { apiBaseUrl } from "@/lib/site";
|
||||
|
||||
export type ContentPageSlug = "home" | "about" | "events" | "blog";
|
||||
@@ -21,6 +22,8 @@ export type SiteContent = {
|
||||
faculty_subline: string;
|
||||
description: string;
|
||||
logo: ContentImage | null;
|
||||
enamad_image: ContentImage | null;
|
||||
enamad_url: string;
|
||||
navigation: Record<ContentPageSlug, string>;
|
||||
contacts: {
|
||||
email: string;
|
||||
@@ -46,16 +49,26 @@ export type HomeContentPage = PageBase & {
|
||||
hero_body: string;
|
||||
hero_image: ContentImage | null;
|
||||
hero_action_label: string;
|
||||
hero_action_url: string;
|
||||
hero_secondary_action_label: string;
|
||||
hero_secondary_action_url: string;
|
||||
featured_events_heading: string;
|
||||
featured_events_intro: string;
|
||||
featured_events_link_label: string;
|
||||
featured_events_url: string;
|
||||
featured_event_action_label: string;
|
||||
featured_posts_heading: string;
|
||||
featured_posts_intro: string;
|
||||
featured_posts_link_label: string;
|
||||
featured_posts_url: string;
|
||||
featured_post_action_label: string;
|
||||
participation_heading: string;
|
||||
participation_body: string;
|
||||
participation_image: ContentImage | null;
|
||||
participation_action_label: string;
|
||||
participation_action_url: string;
|
||||
participation_secondary_action_label: string;
|
||||
participation_secondary_action_url: string;
|
||||
};
|
||||
|
||||
export type AboutContentItem = {
|
||||
@@ -71,17 +84,77 @@ export type AboutContentPage = PageBase & {
|
||||
identity_body: string;
|
||||
mission_heading: string;
|
||||
mission_body: string;
|
||||
vision_heading: string;
|
||||
vision_body: string;
|
||||
activities_heading: string;
|
||||
activities_body: string;
|
||||
values_heading: string;
|
||||
values_body: string;
|
||||
committees_heading: string;
|
||||
committees_body: string;
|
||||
affiliations_heading: string;
|
||||
affiliations_body: string;
|
||||
faq_heading: string;
|
||||
contact_heading: string;
|
||||
members_heading: string;
|
||||
members_intro: string;
|
||||
gallery_heading: string;
|
||||
gallery_intro: string;
|
||||
past_events_heading: string;
|
||||
history_heading: string;
|
||||
audience_heading: string;
|
||||
year_events_heading: string;
|
||||
cta_heading: string;
|
||||
cta_body: string;
|
||||
cta_action_label: string;
|
||||
cta_action_url: string;
|
||||
items: AboutContentItem[];
|
||||
/** @deprecated Use years[].members. Kept for API compatibility. */
|
||||
members: AboutMember[];
|
||||
gallery_images: AboutGalleryImage[];
|
||||
audiences: AboutAudience[];
|
||||
milestones: AboutMilestone[];
|
||||
years: AboutAssociationYear[];
|
||||
};
|
||||
|
||||
export type AboutMember = {
|
||||
name: string;
|
||||
role: string;
|
||||
entry_year: string;
|
||||
bio: string;
|
||||
photo: ContentImage | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type AboutGalleryImage = {
|
||||
image: ContentImage | null;
|
||||
caption: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type AboutAudience = {
|
||||
title: string;
|
||||
text: string;
|
||||
icon: "students" | "alumni" | "faculty" | "partners";
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type AboutMilestone = {
|
||||
year_label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type AboutYearEvent = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
start_time: string;
|
||||
featured_image: ContentImage | null;
|
||||
};
|
||||
|
||||
export type AboutAssociationYear = {
|
||||
year_label: string;
|
||||
position: number;
|
||||
members: AboutMember[];
|
||||
selected_events: AboutYearEvent[];
|
||||
};
|
||||
|
||||
export type EventsContentPage = PageBase & { slug: "events" };
|
||||
@@ -102,6 +175,8 @@ const MOCK_SITE_CONTENT: SiteContent = {
|
||||
faculty_subline: "دانشکده نمونه",
|
||||
description: "این متن نمونه است و پس از ورود محتوای اصلی از پنل مدیریت جایگزین میشود.",
|
||||
logo: null,
|
||||
enamad_image: { url: "/enamad.png", alt: "نشان اعتماد الکترونیکی" },
|
||||
enamad_url: "",
|
||||
navigation: { home: "خانه", about: "درباره ما", events: "رویدادها", blog: "بلاگ" },
|
||||
contacts: {
|
||||
email: "contact@example.invalid",
|
||||
@@ -132,17 +207,27 @@ const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
|
||||
hero_heading: "عنوان نمونه صفحه اصلی",
|
||||
hero_body: "توضیحات نمونه برای معرفی انجمن و فعالیتهای آن.",
|
||||
hero_image: null,
|
||||
hero_action_label: "مشاهده نمونهها",
|
||||
hero_action_label: "مشاهده رویدادها",
|
||||
hero_action_url: "/events",
|
||||
hero_secondary_action_label: "مطالب بلاگ",
|
||||
hero_secondary_action_url: "/blog",
|
||||
featured_events_heading: "رویدادهای نمونه",
|
||||
featured_events_intro: "توضیحات نمونه برای بخش رویدادها.",
|
||||
featured_events_link_label: "همه رویدادها",
|
||||
featured_events_url: "/events",
|
||||
featured_event_action_label: "مشاهده رویداد",
|
||||
featured_posts_heading: "نوشتههای نمونه",
|
||||
featured_posts_intro: "توضیحات نمونه برای بخش نوشتهها.",
|
||||
featured_posts_link_label: "همه نوشتهها",
|
||||
featured_posts_url: "/blog",
|
||||
featured_post_action_label: "خواندن مطلب",
|
||||
participation_heading: "به ما بپیوندید",
|
||||
participation_body: "این متن نمونه راههای مشارکت را توضیح میدهد.",
|
||||
participation_image: null,
|
||||
participation_action_label: "اطلاعات بیشتر",
|
||||
participation_action_url: "/auth",
|
||||
participation_secondary_action_label: "درباره انجمن",
|
||||
participation_secondary_action_url: "/about",
|
||||
},
|
||||
about: {
|
||||
slug: "about",
|
||||
@@ -157,24 +242,59 @@ const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
|
||||
identity_body: "اطلاعات نمونه درباره هویت انجمن.",
|
||||
mission_heading: "ماموریت نمونه",
|
||||
mission_body: "توضیحات نمونه درباره ماموریت انجمن.",
|
||||
vision_heading: "چشمانداز نمونه",
|
||||
vision_body: "جامعهای برای یادگیری، تجربهکردن و رشد در کنار یکدیگر.",
|
||||
activities_heading: "فعالیتهای نمونه",
|
||||
activities_body: "توضیحات نمونه درباره فعالیتها.",
|
||||
activities_body: "کارگاه، گفتوگو و تجربههای عملی در علوم کامپیوتر.",
|
||||
values_heading: "ارزشهای نمونه",
|
||||
values_body: "یادگیری، همکاری و مسئولیتپذیری نمونه.",
|
||||
committees_heading: "کمیتههای نمونه",
|
||||
committees_body: "اطلاعات نمونه درباره کمیتهها.",
|
||||
affiliations_heading: "همکاریهای نمونه",
|
||||
affiliations_body: "اطلاعات نمونه درباره گروههای همکار.",
|
||||
faq_heading: "پرسشهای نمونه",
|
||||
contact_heading: "راههای ارتباطی نمونه",
|
||||
members_heading: "اعضای انجمن",
|
||||
members_intro: "با اعضایی آشنا شوید که برای ساختن تجربههای بهتر در انجمن همکاری میکنند.",
|
||||
gallery_heading: "خاطرات انجمن",
|
||||
gallery_intro: "تصاویری از گردهماییها، برنامهها و لحظههایی که کنار هم ساختهایم.",
|
||||
past_events_heading: "رویدادهای برگزارشده",
|
||||
history_heading: "مسیر انجمن",
|
||||
audience_heading: "برای چه کسانی؟",
|
||||
year_events_heading: "لحظههای هر سال",
|
||||
cta_heading: "به جمع ما بپیوندید",
|
||||
cta_body: "اگر دوست دارید یاد بگیرید و در ساختن تجربههای تازه سهیم شوید، با ما همراه باشید.",
|
||||
cta_action_label: "راههای پیوستن",
|
||||
cta_action_url: "",
|
||||
items: [
|
||||
{ kind: "mission", title: "ماموریت نمونه", text: "این متن نمونه برای توضیح ماموریت انجمن است.", position: 0 },
|
||||
{ kind: "activities", title: "فعالیت نمونه", text: "این متن نمونه یکی از فعالیتهای انجمن را معرفی میکند.", position: 0 },
|
||||
{ kind: "values", title: "ارزش نمونه", text: "این متن نمونه یکی از ارزشهای انجمن را توضیح میدهد.", position: 0 },
|
||||
{ kind: "committees", title: "کمیته نمونه", text: "این متن نمونه برای معرفی یک کمیته است.", position: 0 },
|
||||
{ kind: "affiliations", title: "همکاری نمونه", text: "این متن نمونه برای معرفی یک گروه همکار است.", position: 0 },
|
||||
{ kind: "faq", title: "پرسش نمونه", text: "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید.", position: 0 },
|
||||
],
|
||||
members: [],
|
||||
gallery_images: [
|
||||
{ image: null, caption: "نمونهای از دورهمیهای انجمن", position: 0 },
|
||||
{ image: null, caption: "نمونهای از لحظههای یادگیری", position: 1 },
|
||||
{ image: null, caption: "نمونهای از همکاری دانشجویی", position: 2 },
|
||||
{ image: null, caption: "نمونهای از خاطرات مشترک", position: 3 },
|
||||
],
|
||||
audiences: [
|
||||
{ title: "دانشجویان", text: "برای یادگیری و ساختن تجربههای تازه کنار هم.", icon: "students", position: 0 },
|
||||
{ title: "دانشآموختگان", text: "برای بهاشتراکگذاشتن تجربه و همراهی با نسل بعد.", icon: "alumni", position: 1 },
|
||||
{ title: "همراهان صنعت", text: "برای نزدیکترکردن دانشگاه به جامعه و کار حرفهای.", icon: "partners", position: 2 },
|
||||
],
|
||||
milestones: [
|
||||
{ year_label: "۱۴۰۱", title: "آغاز یک مسیر مشترک", description: "نمونهای از نقطههای مهم در مسیر شکلگیری انجمن.", position: 0 },
|
||||
{ year_label: "۱۴۰۳", title: "گسترش تجربههای یادگیری", description: "نمونهای از رشد برنامهها و همکاریهای دانشجویی.", position: 1 },
|
||||
],
|
||||
years: [
|
||||
{
|
||||
year_label: "۱۴۰۴–۱۴۰۵",
|
||||
position: 0,
|
||||
members: [
|
||||
{ name: "نام عضو نمونه", role: "نقش در انجمن", entry_year: "۱۴۰۴–۱۴۰۵", bio: "معرفی کوتاه نمونه برای این عضو.", photo: null, position: 0 },
|
||||
],
|
||||
selected_events: [
|
||||
{ id: 0, title: "عنوان رویداد نمونه", slug: "", start_time: "2025-10-01T12:00:00Z", featured_image: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
events: {
|
||||
slug: "events",
|
||||
@@ -245,7 +365,7 @@ async function requestContent<T>(path: string): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getCmsSiteContent(): Promise<SiteContent> {
|
||||
export const getCmsSiteContent = cache(async (): Promise<SiteContent> => {
|
||||
if (!CMS_ENABLED) return MOCK_SITE_CONTENT;
|
||||
try {
|
||||
const content = await requestContent<SiteContent>("/site");
|
||||
@@ -253,7 +373,7 @@ export async function getCmsSiteContent(): Promise<SiteContent> {
|
||||
} catch {
|
||||
return MOCK_SITE_CONTENT;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getCmsPageContent<Slug extends ContentPageSlug>(
|
||||
slug: Slug,
|
||||
|
||||
37
src/lib/home-content.ts
Normal file
37
src/lib/home-content.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type * as Types from "@/lib/types";
|
||||
|
||||
export function selectHomeEvents(
|
||||
events: Types.EventListItemSchema[],
|
||||
now = Date.now(),
|
||||
) {
|
||||
const ordered = events.slice().sort(
|
||||
(left, right) => Date.parse(left.start_time) - Date.parse(right.start_time),
|
||||
);
|
||||
const upcoming = ordered.filter(
|
||||
(event) => event.status === "published" && Date.parse(event.start_time) >= now,
|
||||
);
|
||||
|
||||
if (upcoming.length) {
|
||||
return { events: upcoming.slice(0, 3), isArchive: false };
|
||||
}
|
||||
|
||||
const archive = ordered
|
||||
.filter((event) => event.status === "completed" || Date.parse(event.start_time) < now)
|
||||
.reverse()
|
||||
.slice(0, 3);
|
||||
|
||||
return { events: archive, isArchive: archive.length > 0 };
|
||||
}
|
||||
|
||||
export function selectHomePosts(posts: Types.PostListSchema[]) {
|
||||
const recent = posts.slice().sort(
|
||||
(left, right) =>
|
||||
Date.parse(right.published_at || right.created_at) -
|
||||
Date.parse(left.published_at || left.created_at),
|
||||
);
|
||||
const featured = recent.filter((post) => post.is_featured).slice(0, 3);
|
||||
const featuredIds = new Set(featured.map((post) => post.id));
|
||||
const remaining = recent.filter((post) => !featuredIds.has(post.id));
|
||||
|
||||
return [...featured, ...remaining.slice(0, 3 - featured.length)];
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export function NavLink({
|
||||
replace={replace}
|
||||
prefetch={prefetch}
|
||||
className={resolvedClassName}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,384 +1,304 @@
|
||||
import React from 'react';
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowUpLeft,
|
||||
BookOpen,
|
||||
Camera,
|
||||
Eye,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
HeartHandshake,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import ContentActionLink from "@/components/ContentActionLink";
|
||||
import type { AboutAudience, AboutContentPage, SiteContent } from "@/lib/content-cms";
|
||||
import AboutYearArchive from "@/views/about/YearArchive";
|
||||
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { Link } from '@/lib/router';
|
||||
|
||||
// ------ دادههای قابل تنظیم ------
|
||||
const SITE_URL = 'https://east-guilan-ce.ir';
|
||||
const SITE_NAME_FA = 'انجمن علمی کامپیوتر شرق گیلان';
|
||||
const ABOUT_CANONICAL = `${SITE_URL}/about`;
|
||||
const ABOUT_TITLE = `درباره ما | ${SITE_NAME_FA}`;
|
||||
const ABOUT_DESCRIPTION =
|
||||
'آشنایی با تاریخچه، مأموریتها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راههای مشارکت دانشجویان در برنامههای انجمن.';
|
||||
const ABOUT_KEYWORDS =
|
||||
'انجمن علمی کامپیوتر شرق گیلان, انجمن علمی مهندسی کامپیوتر دانشگاه گیلان، انجمن علمی علوم کامپیوتر، انجمن علمی شرق گیلان، انجمن علمی مهندسی کامپیوتر شرق گیلان، دانشکده فنی و مهندسی شرق گیلان، انجمن علمی کامپیوتر, فعالیتهای دانشجویی, انجمنهای علمی ایران, رویدادهای فناوری، برنامه نویسی، انجمن علمی دانشجویی، دانشگاه گیلان، فنی شرق';
|
||||
|
||||
const ABOUT_STRUCTURED_DATA = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AboutPage',
|
||||
name: ABOUT_TITLE,
|
||||
description: ABOUT_DESCRIPTION,
|
||||
url: ABOUT_CANONICAL,
|
||||
mainEntity: {
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME_FA,
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/favicon.ico`,
|
||||
sameAs: [
|
||||
'https://instagram.com/guilance.ir',
|
||||
'https://t.me/guilance',
|
||||
'https://t.me/guilancea'
|
||||
],
|
||||
areaServed: 'IR',
|
||||
contactPoint: [
|
||||
{
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
email: 'eastguilanceassociation@gmail.com',
|
||||
availableLanguage: ['fa'],
|
||||
areaServed: 'IR'
|
||||
}
|
||||
]
|
||||
}
|
||||
type AboutUsProps = {
|
||||
content: AboutContentPage;
|
||||
siteContent: SiteContent;
|
||||
};
|
||||
|
||||
const ORG = {
|
||||
title: 'انجمنهای علمی کامپیوتر گیلان', // تیتر کلی
|
||||
subtitle:
|
||||
'پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاهها و نشستهای تخصصی — با محوریت رشد مهارتهای فنی و مسیرهای شغلی.',
|
||||
foundedYear: '۱۴۰۴',
|
||||
membersApprox: '۱۰۰+',
|
||||
eventsCount: '۱۰+',
|
||||
volunteersCount: '۲۰+',
|
||||
};
|
||||
const audienceIcons = {
|
||||
students: GraduationCap,
|
||||
alumni: UsersRound,
|
||||
faculty: BookOpen,
|
||||
partners: Handshake,
|
||||
} satisfies Record<AboutAudience["icon"], typeof GraduationCap>;
|
||||
|
||||
// اگر چند انجمن زیرمجموعه دارید، اینجا معرفی کنید
|
||||
const ASSOCIATIONS = [
|
||||
{
|
||||
name: 'انجمن علمی مهندسی کامپیوتر دانشکدهی فنی شرق',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رودسر',
|
||||
foundedYear: '۱۳۹۲',
|
||||
about:
|
||||
'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['مهندسی نرمافزار', 'هوش مصنوعی و داده', 'طراحی محصول', 'DevOps/امنیت'],
|
||||
links: {
|
||||
website: 'https://east-guilan-ce.ir',
|
||||
instagram: '"https://instagram.com/guilance.ir',
|
||||
telegram: 'https://t.me/guilance',
|
||||
email: 'eastguilanceassociation@gmail.com',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'انجمن علمی مهندسی کامپیوتر دانشکدهی فنی',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رشت',
|
||||
foundedYear: '۱۳۵۳',
|
||||
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['مهندسی نرمافزار', 'هوش مصنوعی و داده', 'DevOps'],
|
||||
links: {
|
||||
instagram: 'https://instagram.com/ce.guilan',
|
||||
telegram: 'https://t.me/CSAOEF',
|
||||
email: 'cesa@guilan.ac.ir'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'انجمن علمی علوم کامپیوتر دانشکدهی علومپایه',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رشت',
|
||||
foundedYear: '۱۳۵۳',
|
||||
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['امنیت', 'سیستمعامل', 'سختافزار'],
|
||||
links: {
|
||||
instagram: 'https://instagram.com/csguilan',
|
||||
telegram: 'https://t.me/guilanCS',
|
||||
},
|
||||
},
|
||||
const galleryTones = [
|
||||
"bg-gradient-to-br from-primary/20 via-brand-wash to-accent/30",
|
||||
"bg-gradient-to-br from-accent/50 via-secondary to-primary/15",
|
||||
"bg-gradient-to-br from-secondary via-brand-wash to-primary/20",
|
||||
"bg-gradient-to-br from-brand-wash via-accent/25 to-secondary",
|
||||
];
|
||||
|
||||
// کمیتهها/گروههای کاری
|
||||
const COMMITTEES = [
|
||||
{
|
||||
title: 'کمیته آموزش',
|
||||
desc: 'برنامهریزی و اجرای دورهها و کارگاههای مهارتی، هماهنگی با مدرسین و طراحی مسیرهای یادگیری.'
|
||||
},
|
||||
{
|
||||
title: 'کمیته صنعت و اشتغال',
|
||||
desc: 'تعامل با شرکتها، دعوت از متخصصان صنعت، و شبکهسازی برای فرصتهای کارآموزی و استخدام.'
|
||||
},
|
||||
{
|
||||
title: 'کمیته محتوای دیجیتال',
|
||||
desc: 'تولید محتوای آموزشی، خبرنامه، مدیریت شبکههای اجتماعی و پوشش رسانهای رویدادها.'
|
||||
},
|
||||
];
|
||||
function AboutHero({ content, siteContent }: AboutUsProps) {
|
||||
const associationImage = siteContent.logo || {
|
||||
url: "/favicon.ico",
|
||||
alt: siteContent.short_name || "نشان انجمن",
|
||||
};
|
||||
|
||||
// راههای ارتباطی اصلی صفحه
|
||||
const CONTACTS = [
|
||||
{
|
||||
label: 'ایمیل',
|
||||
value: 'eastguilanceassociation@gmail.com',
|
||||
href: 'mailto:eastguilanceassociation@gmail.com',
|
||||
},
|
||||
{
|
||||
label: 'تلگرام',
|
||||
value: '@GuilanCEA',
|
||||
href: 'https://t.me/guilancea',
|
||||
},
|
||||
{
|
||||
label: 'اینستاگرام',
|
||||
value: '@GuilanCE.ir',
|
||||
href: 'https://instagram.com/guilance.ir',
|
||||
},
|
||||
{
|
||||
label: 'وبسایت',
|
||||
value: 'east-guilan-ce.ir',
|
||||
href: 'https://east-guilan-ce.ir',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AboutUs() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{ABOUT_TITLE}</title>
|
||||
<meta name="description" content={ABOUT_DESCRIPTION} />
|
||||
<meta name="keywords" content={ABOUT_KEYWORDS} />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href={ABOUT_CANONICAL} />
|
||||
<meta property="og:title" content={ABOUT_TITLE} />
|
||||
<meta property="og:description" content={ABOUT_DESCRIPTION} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={ABOUT_CANONICAL} />
|
||||
<meta property="og:site_name" content={SITE_NAME_FA} />
|
||||
<meta property="og:image" content={`${SITE_URL}/favicon.ico`} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ABOUT_TITLE} />
|
||||
<meta name="twitter:description" content={ABOUT_DESCRIPTION} />
|
||||
<meta name="twitter:image" content={`${SITE_URL}/favicon.ico`} />
|
||||
<script type="application/ld+json">{JSON.stringify(ABOUT_STRUCTURED_DATA)}</script>
|
||||
</Helmet>
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-muted/40 to-transparent">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight text-foreground">{ORG.title}</h1>
|
||||
<p className="mt-3 text-muted-foreground leading-7">{ORG.subtitle}</p>
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/50" aria-labelledby="about-title">
|
||||
<div className="container mx-auto grid max-w-6xl items-start gap-9 px-4 py-12 md:py-16 lg:grid-cols-[1.08fr_0.92fr] lg:gap-14 lg:py-20">
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
{siteContent.faculty_subline ? (
|
||||
<p className="mb-4 inline-flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<span className="h-2 w-2 rounded-full bg-accent" aria-hidden="true" />
|
||||
{siteContent.faculty_subline}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 id="about-title" className="text-3xl font-extrabold leading-[1.45] tracking-tight text-foreground md:text-5xl">
|
||||
{content.title || content.identity_heading}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl whitespace-pre-line text-base leading-8 text-muted-foreground md:text-lg">
|
||||
{content.intro || content.identity_body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* آمار کوتاه */}
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Stat label="سال تأسیس" value={ORG.foundedYear} />
|
||||
<Stat label="اعضا" value={ORG.membersApprox} />
|
||||
<Stat label="رویدادها" value={ORG.eventsCount} />
|
||||
<Stat label="داوطلبان" value={ORG.volunteersCount} />
|
||||
<div className="relative mx-auto w-full max-w-xl">
|
||||
<div className="relative grid min-h-[260px] place-items-center overflow-hidden rounded-[2rem] border border-primary/20 bg-primary p-8 md:min-h-[340px]">
|
||||
<div className="absolute -left-10 -top-16 h-56 w-56 rounded-full border border-primary-foreground/20" aria-hidden="true" />
|
||||
<div className="absolute -bottom-24 -right-12 h-64 w-64 rounded-full border border-accent/45" aria-hidden="true" />
|
||||
<div className="relative grid h-36 w-36 place-items-center rounded-[2rem] border border-primary-foreground/25 bg-background/10 p-5 md:h-48 md:w-48">
|
||||
<Image
|
||||
src={associationImage.url}
|
||||
alt={associationImage.alt}
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
priority
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/95 px-4 py-3 shadow-sm backdrop-blur-sm md:bottom-6 md:left-6 md:right-6">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-bold text-foreground">
|
||||
<Sparkles aria-hidden="true" className="h-4 w-4 text-primary" />
|
||||
{siteContent.short_name || siteContent.official_name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{content.identity_heading}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{/* درباره ما */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader title="ماموریت ما" subtitle="چرا اینجا هستیم؟" />
|
||||
<div className="p-6 pt-0 text-sm leading-7 text-muted-foreground">
|
||||
<p>
|
||||
توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت، ایجاد شبکههای حرفهای و فراهمکردن مسیرهای یادگیری
|
||||
پروژهمحور؛ از معرفی نقشها و مسیرهای شغلی تا تمرین مهارتهای نرم و فنی.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
function PurposeSection({ content }: { content: AboutContentPage }) {
|
||||
const cards = [
|
||||
{ title: content.mission_heading, text: content.mission_body, Icon: Lightbulb, tone: "bg-primary text-primary-foreground" },
|
||||
{ title: content.vision_heading, text: content.vision_body, Icon: Eye, tone: "bg-card text-card-foreground" },
|
||||
{ title: content.values_heading, text: content.values_body, Icon: HeartHandshake, tone: "bg-accent text-accent-foreground" },
|
||||
].filter((card) => card.title || card.text);
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="ارزشهای ما" subtitle="چه چیزهایی برایمان مهم است؟" />
|
||||
<ul className="p-6 pt-0 grid grid-cols-1 gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
||||
<li className="rounded-xl border p-3">یادگیری پیوسته و اشتراک دانش</li>
|
||||
<li className="rounded-xl border p-3">کیفیت، شفافیت و مسئولیتپذیری</li>
|
||||
<li className="rounded-xl border p-3">فرصت برابر برای مشارکت</li>
|
||||
<li className="rounded-xl border p-3">ارتباط مؤثر با صنعت و جامعه</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
if (!cards.length && !content.activities_body) return null;
|
||||
|
||||
{/* تماس سریع */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader title="راههای ارتباطی" subtitle="با ما در ارتباط باشید" />
|
||||
<div className="p-6 pt-0 space-y-3">
|
||||
{CONTACTS.map((c) => (
|
||||
<a
|
||||
key={c.label}
|
||||
href={c.href}
|
||||
target={c.href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={c.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="flex items-center justify-between rounded-xl border p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm text-foreground">{c.label}</span>
|
||||
<span className="text-xs text-muted-foreground ltr:ml-2 rtl:mr-2 truncate">{c.value}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="ساعات پاسخگویی" subtitle="پشتیبانی داوطلبانه" />
|
||||
<div className="p-6 pt-0 text-sm text-muted-foreground">
|
||||
<p>روزهای شنبه تا چهارشنبه، ساعت ۱۰ تا ۱۸</p>
|
||||
<p className="mt-2">پاسخها توسط تیم داوطلبان انجام میشود.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
return (
|
||||
<section aria-label="ماموریت، چشمانداز و ارزشهای انجمن">
|
||||
{cards.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{cards.map(({ title, text, Icon, tone }, index) => (
|
||||
<article
|
||||
key={`${title}-${index}`}
|
||||
className={`relative overflow-hidden rounded-3xl border border-border/80 p-6 md:p-7 ${tone}`}
|
||||
>
|
||||
<span className="mb-5 grid h-11 w-11 place-items-center rounded-2xl border border-current/15 bg-background/15">
|
||||
<Icon aria-hidden="true" className="h-5 w-5" />
|
||||
</span>
|
||||
{title ? <h2 className="text-xl font-bold leading-8">{title}</h2> : null}
|
||||
{text ? <p className="mt-3 whitespace-pre-line text-sm leading-7 opacity-90 md:text-base">{text}</p> : null}
|
||||
{index === 0 ? <span className="pointer-events-none absolute -bottom-16 -left-10 h-40 w-40 rounded-full border border-current/15" aria-hidden="true" /> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* انجمنها */}
|
||||
<section className="bg-muted/20">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">انجمنها</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">اطلاعات انجمنهای زیرمجموعه/همکار</p>
|
||||
{content.activities_heading || content.activities_body ? (
|
||||
<details className="group mt-4 rounded-2xl border border-border bg-card px-5 py-4">
|
||||
<summary className="flex min-h-8 cursor-pointer list-none items-center justify-between gap-4 font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<BookOpen aria-hidden="true" className="h-5 w-5 text-primary" />
|
||||
{content.activities_heading}
|
||||
</span>
|
||||
<span className="text-xl text-primary transition-transform group-open:rotate-45" aria-hidden="true">+</span>
|
||||
</summary>
|
||||
{content.activities_body ? <p className="mt-3 whitespace-pre-line border-t border-border pt-3 text-sm leading-7 text-muted-foreground">{content.activities_body}</p> : null}
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
{ASSOCIATIONS.map((a) => (
|
||||
<Card key={a.name}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{a.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{a.university}{a.city ? ` • ${a.city}` : ''}{' '}
|
||||
{a.foundedYear && `• تأسیس: ${a.foundedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-muted-foreground">{a.about}</p>
|
||||
function AudienceSection({ content }: { content: AboutContentPage }) {
|
||||
if (!content.audiences.length) return null;
|
||||
|
||||
{a.focusAreas?.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{a.focusAreas.map((f) => (
|
||||
<span key={f} className="rounded-full border px-3 py-1 text-xs text-muted-foreground">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
return (
|
||||
<section aria-labelledby="about-audience-title">
|
||||
<div className="mb-6 max-w-2xl">
|
||||
<h2 id="about-audience-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
|
||||
{content.audience_heading}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{content.audiences.map((audience, index) => {
|
||||
const Icon = audienceIcons[audience.icon] || UsersRound;
|
||||
return (
|
||||
<article
|
||||
key={`${audience.position}-${audience.title}`}
|
||||
className={`flex min-h-44 flex-col justify-start rounded-3xl border border-border p-5 md:p-6 ${index === 1 ? "bg-primary text-primary-foreground" : "bg-card text-card-foreground"}`}
|
||||
>
|
||||
<span className="mb-5 grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-current/15 bg-background/15">
|
||||
<Icon aria-hidden="true" className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold leading-7">{audience.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 opacity-90">{audience.text}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{a.links?.website && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.website} target="_blank" rel="noreferrer">وبسایت</a>
|
||||
)}
|
||||
{a.links?.instagram && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.instagram} target="_blank" rel="noreferrer">اینستاگرام</a>
|
||||
)}
|
||||
{a.links?.telegram && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.telegram} target="_blank" rel="noreferrer">تلگرام</a>
|
||||
)}
|
||||
{a.links?.email && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={`mailto:${a.links.email}`}>ایمیل</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
function AssociationHistory({ content }: { content: AboutContentPage }) {
|
||||
if (!content.milestones.length) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-history-title" className="grid gap-8 lg:grid-cols-[0.72fr_1.28fr] lg:gap-14">
|
||||
<div className="max-w-md">
|
||||
<h2 id="about-history-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
|
||||
{content.history_heading}
|
||||
</h2>
|
||||
</div>
|
||||
<ol className="relative space-y-7 md:space-y-9">
|
||||
<span className="pointer-events-none absolute bottom-1 right-1.5 top-1 w-px bg-primary/25" aria-hidden="true" />
|
||||
{content.milestones.map((milestone, index) => (
|
||||
<li key={`${milestone.year_label}-${milestone.position}-${milestone.title}`} className="relative flex items-start gap-5">
|
||||
<span className={`relative z-10 mt-1.5 h-3 w-3 shrink-0 rounded-full border-2 border-background ${index === content.milestones.length - 1 ? "bg-accent" : "bg-primary"}`} aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-2 text-sm font-bold text-primary">{milestone.year_label}</p>
|
||||
<h3 className="text-lg font-bold leading-7 text-foreground">{milestone.title}</h3>
|
||||
<p className="mt-2 max-w-2xl whitespace-pre-line text-sm leading-7 text-muted-foreground">{milestone.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoriesGallery({ content }: { content: AboutContentPage }) {
|
||||
if (!content.gallery_images.length) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-gallery-title">
|
||||
<div className="mb-6 max-w-2xl">
|
||||
<h2 id="about-gallery-title" className="inline-flex items-center gap-3 text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
|
||||
<Camera aria-hidden="true" className="h-6 w-6 text-primary" />
|
||||
{content.gallery_heading}
|
||||
</h2>
|
||||
{content.gallery_intro ? <p className="mt-2 text-sm leading-7 text-muted-foreground md:text-base">{content.gallery_intro}</p> : null}
|
||||
</div>
|
||||
<div className="grid auto-rows-[150px] grid-cols-2 gap-3 md:auto-rows-[190px] md:grid-cols-4 md:gap-4">
|
||||
{content.gallery_images.map((item, index) => (
|
||||
<figure key={`${item.position}-${item.caption}`} className={`group relative overflow-hidden rounded-3xl border border-border ${index % 5 === 0 ? "row-span-2 md:col-span-2" : ""}`}>
|
||||
{item.image ? (
|
||||
<Image
|
||||
src={item.image.url}
|
||||
alt={item.image.alt}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className={`grid h-full place-items-center ${galleryTones[index % galleryTones.length]}`} role="img" aria-label={item.caption}>
|
||||
<span className="grid h-16 w-16 place-items-center rounded-2xl border border-primary/20 bg-card/75 text-primary">
|
||||
<Camera aria-hidden="true" className="h-7 w-7" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.caption ? <figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 to-transparent px-4 pb-4 pt-10 text-sm font-semibold text-white">{item.caption}</figcaption> : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClosingSection({ content }: { content: AboutContentPage }) {
|
||||
const faqItems = content.items.filter((item) => item.kind === "faq");
|
||||
if (!faqItems.length && !content.cta_heading && !content.cta_body && !content.cta_action_label) return null;
|
||||
|
||||
return (
|
||||
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]" aria-label="پرسشهای متداول و پیوستن به انجمن">
|
||||
{faqItems.length ? (
|
||||
<div>
|
||||
<h2 className="mb-4 text-2xl font-bold leading-relaxed text-foreground">{content.faq_heading}</h2>
|
||||
<div className="space-y-3">
|
||||
{faqItems.map((item) => (
|
||||
<details key={`${item.position}-${item.title}`} className="group rounded-2xl border border-border bg-card p-4 open:bg-brand-wash/55">
|
||||
<summary className="flex min-h-8 w-full cursor-pointer list-none items-center justify-between gap-4 font-semibold leading-7 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="min-w-0 flex-1">{item.title}</span>
|
||||
<span className="shrink-0 text-xl text-primary transition-transform group-open:rotate-45" aria-hidden="true">+</span>
|
||||
</summary>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-muted-foreground">{item.text}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* کمیتهها */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">کمیتهها و گروههای کاری</h2>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
{COMMITTEES.map((c) => (
|
||||
<Card key={c.title}>
|
||||
<div className="p-5">
|
||||
<h3 className="text-base font-semibold text-foreground">{c.title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-7">{c.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<aside className="flex flex-col items-start justify-between gap-7 rounded-3xl bg-primary p-6 text-primary-foreground md:p-8">
|
||||
<div>
|
||||
<span className="mb-5 grid h-11 w-11 place-items-center rounded-2xl border border-primary-foreground/20 bg-background/10">
|
||||
<HeartHandshake aria-hidden="true" className="h-5 w-5" />
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold leading-relaxed">{content.cta_heading}</h2>
|
||||
{content.cta_body ? <p className="mt-3 max-w-md text-sm leading-7 opacity-90">{content.cta_body}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
{content.cta_action_label ? (
|
||||
content.cta_action_url ? (
|
||||
<ContentActionLink
|
||||
href={content.cta_action_url}
|
||||
fallbackHref="/"
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-accent px-5 font-bold text-accent-foreground transition-colors hover:bg-accent/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-primary"
|
||||
>
|
||||
{content.cta_action_label}<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</ContentActionLink>
|
||||
) : (
|
||||
<span aria-disabled="true" className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-primary-foreground/35 px-5 font-bold text-primary-foreground/80">
|
||||
{content.cta_action_label}<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{/* پرسشهای متداول */}
|
||||
<section className="bg-muted/20">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">پرسشهای متداول</h2>
|
||||
<div className="mt-6 space-y-3">
|
||||
<FAQ q="چطور در رویدادها شرکت کنم؟" a="برای ثبتنام در رویدادها ابتدا باید حسابکاربری با کددانشجویی خود ایجاد کنید و سپس از طریق صفحهی ثبتنام در رویداد موردنظرتان شرکت کنید :)" />
|
||||
<FAQ q="آیا امکان همکاری انجمنها/شرکتها هم وجود دارد؟" a="بله، ما همیشه آمادهی همکاری با سازمانها و انجمنهای مختلف دانشگاهها جهت توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت و ایجاد شبکههای حرفهای هستیم." />
|
||||
<FAQ q="برای سخنرانی/منتورینگ به چه چیزهایی نیاز است؟" a="رزومه کوتاه، موضوع پیشنهادی و زمانهای دسترسپذیرتان را ارسال کنید تا هماهنگ کنیم." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* نقشه و آدرس اختیاری */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">نشانی دانشکده</h2>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<div className="p-5 text-sm text-muted-foreground leading-7">
|
||||
<p>آدرس: گیلان، رودسر، واجارگاه، دانشکدهی فنی و مهندسی شرق گیلان</p>
|
||||
<p>کدپستی: ۴۴۹۱۸۹۸۵۶۶</p>
|
||||
<p>تلفن: ۰۱۳-۴۲۶۸۸۴۴۷</p>
|
||||
<p className="mt-3">برای هماهنگی حضوری از قبل پیام بدهید.</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="rounded-xl border overflow-hidden min-h-[280px] bg-muted/40 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<iframe
|
||||
src="https://maps.google.com/maps?q=37.06285,50.42324&hl=fa&z=16&output=embed"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="w-full aspect-video border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-gradient-to-t from-muted/40 to-transparent">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-12 text-center">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-foreground">مایل به همکاری هستید؟</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">برای مشارکت داوطلبانه، سخنرانی، اسپانسری یا همکاری صنعتی به ما پیام دهید.</p>
|
||||
<div className="mt-4 inline-flex gap-3">
|
||||
<a href="mailto:eastguilanceassociation@gmail.com" className="rounded-xl border px-5 py-2 text-sm hover:bg-muted/50">ایجاد ارتباط</a>
|
||||
<a href="https://t.me/guilancea" target="_blank" rel="noreferrer" className="rounded-xl bg-primary px-5 py-2 text-sm text-primary-foreground">
|
||||
پیام در تلگرام
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
export default function AboutUs({ content, siteContent }: AboutUsProps) {
|
||||
return (
|
||||
<main className="bg-background" dir="rtl">
|
||||
<AboutHero content={content} siteContent={siteContent} />
|
||||
<div className="container mx-auto max-w-6xl space-y-16 px-4 py-12 md:space-y-20 md:py-16">
|
||||
<PurposeSection content={content} />
|
||||
<AudienceSection content={content} />
|
||||
<AssociationHistory content={content} />
|
||||
<AboutYearArchive
|
||||
years={content.years}
|
||||
heading={content.members_heading}
|
||||
intro={content.members_intro}
|
||||
eventsHeading={content.year_events_heading}
|
||||
/>
|
||||
<MemoriesGallery content={content} />
|
||||
<ClosingSection content={content} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- اجزای کوچک ---------
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-4 text-center">
|
||||
<div className="text-2xl font-extrabold text-foreground">{value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className = '' }: React.PropsWithChildren<{ className?: string }>) {
|
||||
return <div className={`rounded-2xl border bg-card text-card-foreground shadow-sm ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="p-6 pb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQ({ q, a }: { q: string; a: string }) {
|
||||
return (
|
||||
<details className="rounded-xl border bg-background p-4">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium text-foreground">{q}</summary>
|
||||
<p className="mt-2 text-sm leading-7 text-muted-foreground">{a}</p>
|
||||
</details>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import type * as Types from "@/lib/types";
|
||||
import type { BlogContentPage } from "@/lib/content-cms";
|
||||
import { cn, formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
|
||||
|
||||
type BlogProps = {
|
||||
content: BlogContentPage;
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
initialSearch?: string;
|
||||
initialCategory?: string;
|
||||
@@ -102,6 +104,7 @@ function BlogBannerSlider({ banners }: { banners: Types.BlogBannerSchema[] }) {
|
||||
}
|
||||
|
||||
export default function Blog({
|
||||
content,
|
||||
initialPosts = [],
|
||||
initialSearch = "",
|
||||
initialCategory = "",
|
||||
@@ -347,10 +350,9 @@ export default function Blog({
|
||||
|
||||
<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>
|
||||
<h1 className="text-4xl font-black tracking-tight">{content.title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||
نوشتههای آموزشی، تجربههای دانشجویی و یادداشتهای تخصصی اعضای انجمن علمی.
|
||||
{content.intro}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-md items-center gap-2">
|
||||
|
||||
@@ -12,8 +12,11 @@ import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import type { EventsContentPage } from "@/lib/content-cms";
|
||||
|
||||
type EventsProps = {
|
||||
content: EventsContentPage;
|
||||
siteName: string;
|
||||
initialEvents?: Types.EventListItemSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
@@ -49,6 +52,8 @@ function notAvailableReasonFa(event: Types.EventListItemSchema) {
|
||||
}
|
||||
|
||||
export default function Events({
|
||||
content,
|
||||
siteName,
|
||||
initialEvents = [],
|
||||
initialSearch = "",
|
||||
}: EventsProps) {
|
||||
@@ -58,10 +63,8 @@ export default function Events({
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
|
||||
|
||||
const siteName = "East Guilan CE";
|
||||
const pageTitle = `Events | ${siteName}`;
|
||||
const pageDescription =
|
||||
"Discover upcoming and past events organized by the East Guilan Computer Engineering Association, including workshops, competitions, and community programs.";
|
||||
const pageTitle = content.seo.title || content.title;
|
||||
const pageDescription = content.seo.description || content.intro;
|
||||
const canonicalUrl = `${siteUrl}/events`;
|
||||
|
||||
const toAbsoluteUrl = (url?: string | null) => {
|
||||
@@ -189,7 +192,8 @@ export default function Events({
|
||||
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">رویدادها</h1>
|
||||
<h1 className="mb-3 text-4xl font-bold">{content.title}</h1>
|
||||
{content.intro ? <p className="mb-8 max-w-3xl leading-8 text-muted-foreground">{content.intro}</p> : null}
|
||||
|
||||
<div className="mb-8">
|
||||
<Input
|
||||
|
||||
@@ -1,130 +1,121 @@
|
||||
/**
|
||||
* Home page highlighting the association mission, key offerings, and primary calls to action.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import { ArrowUpLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link } from "@/lib/router";
|
||||
import FeaturedEvents from "@/components/home/FeaturedEvents";
|
||||
import FeaturedPosts from "@/components/home/FeaturedPosts";
|
||||
import ContentActionLink from "@/components/ContentActionLink";
|
||||
import type { HomeContentPage, SiteContent } from "@/lib/content-cms";
|
||||
import type { EventListItemSchema, PostListSchema } from "@/lib/types";
|
||||
|
||||
const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان";
|
||||
const heroDescription =
|
||||
"با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.";
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: heroTitle,
|
||||
url: "https://east-guilan-ce.ir",
|
||||
sameAs: ["https://east-guilan-ce.ir/blog", "https://east-guilan-ce.ir/events"],
|
||||
description: heroDescription,
|
||||
logo: "https://east-guilan-ce.ir/favicon.ico",
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "admin@east-guilan-ce.ir",
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"]
|
||||
}
|
||||
type HomeProps = {
|
||||
content: HomeContentPage;
|
||||
siteContent: SiteContent;
|
||||
events: EventListItemSchema[];
|
||||
posts: PostListSchema[];
|
||||
isEventArchive: boolean;
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
export default function Home({ content, siteContent, events, posts, isEventArchive }: HomeProps) {
|
||||
const heroImage = content.hero_image || siteContent.logo || {
|
||||
url: "/favicon.ico",
|
||||
alt: siteContent.short_name || "نشان انجمن",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{heroTitle}</title>
|
||||
<meta name="description" content={heroDescription} />
|
||||
<meta property="og:title" content={heroTitle} />
|
||||
<meta property="og:description" content={heroDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://east-guilan-ce.ir" />
|
||||
<meta property="og:image" content="https://east-guilan-ce.ir/favicon.ico" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={heroTitle} />
|
||||
<meta name="twitter:description" content={heroDescription} />
|
||||
<script type="application/ld+json">{JSON.stringify(structuredData)}</script>
|
||||
</Helmet>
|
||||
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<header className="bg-gradient-to-b from-primary/10 to-background py-20">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h1 className="mb-6 text-4xl font-extrabold leading-tight text-primary sm:text-5xl">{heroTitle}</h1>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground sm:text-xl">{heroDescription}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link to="/events">
|
||||
<Button size="lg" aria-label="مشاهده رویدادهای انجمن">
|
||||
مشاهده رویدادها
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/blog">
|
||||
<Button size="lg" variant="outline" aria-label="مطالعه مقالات آموزشی">
|
||||
خواندن مقالات
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="py-16" aria-labelledby="about-section">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 id="about-section" className="mb-12 text-center text-3xl font-bold">
|
||||
درباره انجمن
|
||||
</h2>
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>رویدادهای تخصصی</CardTitle>
|
||||
<CardDescription>برگزاری کارگاهها، سمینارها و نشستهای علمی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
با شرکت در رویدادهای ما، دانش و مهارتهای خود را در کنار اساتید و متخصصان ارتقا دهید.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>مقالات آموزشی</CardTitle>
|
||||
<CardDescription>دسترسی به مطالب آموزشی و پژوهشی باکیفیت</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
تازهترین مقالات تخصصی در حوزههای مختلف علوم کامپیوتر برای یادگیری و توسعه فردی.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>جامعه فعال</CardTitle>
|
||||
<CardDescription>ارتباط با دانشجویان و اساتید علاقهمند به فناوری</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
به جامعهای پویا بپیوندید و از فرصتهای همکاری، شبکهسازی و رشد شخصی بهرهمند شوید.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-primary/5 py-16" aria-labelledby="cta-section">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 id="cta-section" className="mb-4 text-3xl font-bold">
|
||||
آماده عضویت هستید؟
|
||||
</h2>
|
||||
<p className="mx-auto mb-8 max-w-xl text-muted-foreground">
|
||||
همین حالا به جمع ما بپیوندید و از برنامهها و فرصتهای یادگیری انجمن علمی کامپیوتر دانشگاه گیلان استفاده کنید.
|
||||
<main className="bg-background" dir="rtl">
|
||||
<section className="border-b border-border/70 bg-brand-wash/45" aria-labelledby="home-hero-title">
|
||||
<div className="container mx-auto grid items-center gap-8 px-4 py-10 md:grid-cols-[1.15fr_0.85fr] md:gap-12 md:py-16">
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold text-brand-deep">
|
||||
{siteContent.faculty_subline}
|
||||
</p>
|
||||
<Link to="/auth">
|
||||
<Button size="lg" aria-label="ثبت نام در انجمن علمی کامپیوتر">
|
||||
ثبتنام کنید
|
||||
<h1 id="home-hero-title" className="max-w-3xl text-3xl font-extrabold leading-[1.45] tracking-tight text-brand-deep sm:text-4xl md:text-5xl">
|
||||
{content.hero_heading || content.title}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-muted-foreground md:text-lg">
|
||||
{content.intro}
|
||||
</p>
|
||||
{content.hero_body ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||
{content.hero_body}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild className="min-h-11 rounded-[10px] px-5">
|
||||
<ContentActionLink
|
||||
href={content.hero_action_url}
|
||||
fallbackHref="/events"
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
{content.hero_action_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</ContentActionLink>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button asChild variant="outline" className="home-hero-secondary min-h-11 rounded-[10px] border-primary/35 bg-background px-5 text-primary">
|
||||
<ContentActionLink href={content.hero_secondary_action_url} fallbackHref="/blog">
|
||||
{content.hero_secondary_action_label || siteContent.navigation.blog}
|
||||
</ContentActionLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
|
||||
<div className="relative grid min-h-48 place-items-center overflow-hidden rounded-2xl border border-border/80 bg-brand-wash px-6 py-10 md:min-h-[320px]">
|
||||
<div className="pointer-events-none absolute inset-4 rounded-xl border border-primary/20" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1.5 bg-accent" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full border border-primary/20" aria-hidden="true" />
|
||||
<Image
|
||||
src={heroImage.url}
|
||||
alt={heroImage.alt || "تصویر معرفی انجمن"}
|
||||
width={240}
|
||||
height={240}
|
||||
unoptimized
|
||||
className="relative z-10 h-36 w-36 object-contain drop-shadow-xl md:h-48 md:w-48"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FeaturedEvents content={content} events={events} isArchive={isEventArchive} />
|
||||
<FeaturedPosts content={content} siteContent={siteContent} posts={posts} />
|
||||
|
||||
<section className="bg-primary text-primary-foreground" aria-labelledby="home-participation-title">
|
||||
<div className="container mx-auto flex flex-col gap-6 px-4 py-12 md:flex-row md:items-center md:justify-between md:py-16">
|
||||
{content.participation_image ? (
|
||||
<div className="hidden h-24 w-24 shrink-0 place-items-center overflow-hidden rounded-2xl border border-primary-foreground/20 bg-background/10 p-2 md:grid">
|
||||
<Image
|
||||
src={content.participation_image.url}
|
||||
alt={content.participation_image.alt || ""}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-w-2xl">
|
||||
<h2 id="home-participation-title" className="text-2xl font-bold leading-relaxed md:text-3xl">
|
||||
{content.participation_heading}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-7 opacity-90 md:text-base">
|
||||
{content.participation_body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild className="min-h-11 rounded-[10px] bg-accent px-5 text-accent-foreground hover:bg-accent/90">
|
||||
<ContentActionLink href={content.participation_action_url} fallbackHref="/auth" className="inline-flex items-center gap-2">
|
||||
{content.participation_action_label}
|
||||
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</ContentActionLink>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="min-h-11 rounded-[10px] border-primary-foreground/45 bg-transparent px-5 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground">
|
||||
<ContentActionLink href={content.participation_secondary_action_url} fallbackHref="/about">
|
||||
{content.participation_secondary_action_label}
|
||||
</ContentActionLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/views/about/YearArchive.tsx
Normal file
162
src/views/about/YearArchive.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { ArrowUpLeft, CalendarDays } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { AboutAssociationYear, AboutMember, AboutYearEvent } from "@/lib/content-cms";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
|
||||
type YearArchiveProps = {
|
||||
years: AboutAssociationYear[];
|
||||
heading: string;
|
||||
intro: string;
|
||||
eventsHeading: string;
|
||||
};
|
||||
|
||||
function memberInitial(name: string) {
|
||||
return name.trim().slice(0, 1) || "؟";
|
||||
}
|
||||
|
||||
function MemberCard({ member }: { member: AboutMember }) {
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-2xl border border-border bg-card transition-colors hover:border-primary/40">
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-brand-wash">
|
||||
{member.photo ? (
|
||||
<Image
|
||||
src={member.photo.url}
|
||||
alt={member.photo.alt || member.name}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center bg-gradient-to-br from-accent/40 via-brand-wash to-secondary">
|
||||
<span className="grid h-20 w-20 place-items-center rounded-full border border-primary/20 bg-card/90 text-3xl font-bold text-primary shadow-sm" aria-label={member.name}>
|
||||
{memberInitial(member.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs font-semibold text-primary">{member.role}</p>
|
||||
<h3 className="mt-1 text-lg font-bold leading-7 text-foreground">{member.name}</h3>
|
||||
{member.bio ? <p className="mt-2 line-clamp-3 text-sm leading-7 text-muted-foreground">{member.bio}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function EventPoster({ event }: { event: AboutYearEvent }) {
|
||||
const imageUrl = event.featured_image
|
||||
? toAbsoluteUrl(event.featured_image.url, apiBaseUrl)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-2xl border border-border bg-card transition-colors hover:border-primary/40">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-brand-wash">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={event.featured_image?.alt || `پوستر ${event.title}`}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 90vw, 33vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center bg-gradient-to-br from-accent/40 via-brand-wash to-secondary text-primary">
|
||||
<CalendarDays aria-hidden="true" className="h-10 w-10" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-24 items-center justify-between gap-3 p-4">
|
||||
<div className="min-w-0">
|
||||
<h4 className="line-clamp-2 font-bold leading-7 text-foreground">{event.title}</h4>
|
||||
{event.start_time ? <time className="mt-1 block text-xs text-muted-foreground" dateTime={event.start_time}>{formatJalali(event.start_time, false)}</time> : null}
|
||||
</div>
|
||||
{event.slug ? <ArrowUpLeft aria-hidden="true" className="h-5 w-5 shrink-0 text-primary transition-transform group-hover:-translate-x-1 group-hover:-translate-y-1" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function YearArchive({ years, heading, intro, eventsHeading }: YearArchiveProps) {
|
||||
const [selectedLabel, setSelectedLabel] = useState(years[years.length - 1]?.year_label ?? "");
|
||||
const selectedYear = years.find((year) => year.year_label === selectedLabel) ?? years[years.length - 1];
|
||||
|
||||
if (!years.length || !selectedYear) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-years-title">
|
||||
<div className="mb-7 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<h2 id="about-years-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">{heading}</h2>
|
||||
{intro ? <p className="mt-2 text-sm leading-7 text-muted-foreground md:text-base">{intro}</p> : null}
|
||||
</div>
|
||||
<p className="shrink-0 rounded-full border border-border bg-card px-4 py-2 text-sm font-semibold text-muted-foreground">
|
||||
{selectedYear.year_label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-7 overflow-x-auto pb-2" role="group" aria-label="انتخاب سال فعالیت انجمن">
|
||||
<div className="flex min-w-max gap-2">
|
||||
{years.map((year, index) => (
|
||||
<button
|
||||
key={`${year.year_label}-${index}`}
|
||||
type="button"
|
||||
aria-pressed={year.year_label === selectedYear.year_label}
|
||||
onClick={() => setSelectedLabel(year.year_label)}
|
||||
className={`min-h-11 rounded-full border px-5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
year.year_label === selectedYear.year_label
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:bg-brand-wash hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{year.year_label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-9">
|
||||
<div>
|
||||
<h3 className="sr-only">{selectedYear.year_label} — {heading}</h3>
|
||||
{selectedYear.members.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{selectedYear.members.map((member, index) => <MemberCard key={`${member.name}-${member.role}-${index}`} member={member} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-border bg-card/60 px-5 py-6 text-sm text-muted-foreground">برای این سال هنوز عضوی ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedYear.selected_events.length ? (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
<h3 className="text-base font-bold text-foreground">{eventsHeading}</h3>
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{selectedYear.selected_events.map((event) => (
|
||||
event.slug ? (
|
||||
<Link
|
||||
key={event.id}
|
||||
to={`/events/${encodeURIComponent(event.slug)}`}
|
||||
aria-label={`${event.title}؛ مشاهده جزئیات رویداد`}
|
||||
className="block rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<EventPoster event={event} />
|
||||
</Link>
|
||||
) : <EventPoster key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user