Polish portfolio, blog, and site chrome for clearer browsing.

Add patterned title bands, lightbox gallery, project link bar, footer stats/credit, gradient link hovers, and more reliable blog covers/body rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-23 22:17:25 +03:30
co-authored by Cursor
parent 37d8e4be91
commit 908d86939c
19 changed files with 710 additions and 193 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+86 -35
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent";
import {
@@ -15,6 +16,28 @@ type BlogDetailPageProps = {
params: Promise<{ slug: string }>;
};
function stripLeadingCoverImage(html: string, cover: string | null): string {
if (!html) return html;
let next = html.replace(
/^\s*<p[^>]*>\s*<img[^>]*>\s*<\/p>/i,
"",
);
if (cover) {
const escaped = cover.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
next = next.replace(
new RegExp(
`<p[^>]*>\\s*<img[^>]*src=["']${escaped}["'][^>]*>\\s*<\\/p>`,
"i",
),
"",
);
}
return next.trim();
}
export async function generateMetadata({
params,
}: BlogDetailPageProps): Promise<Metadata> {
@@ -45,54 +68,82 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const cover = getBlogCoverImage(blog);
const date = formatFaDate(blog.publishedAt);
const author = authorName(blog.author);
const category = blog.categoryName;
const bodyHtml = blog.mainTextHtml
? stripLeadingCoverImage(blog.mainTextHtml, cover)
: null;
const titleEn = category || "Blog";
return (
<ContentPageShell>
<ContentPageShell flushTop>
<article className="pb-20 lg:pb-28">
<div className="mx-auto max-w-3xl px-5 lg:px-8">
<div className="reveal mb-8">
<Link
href="/blog"
className="link-hover mb-6 inline-flex text-sm text-muted"
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+2.6rem)]"
>
بازگشت به مقالات
</Link>
{titleEn}
</p>
{blog.categoryName ? (
<p className="latin title-en mb-3 text-muted">
{blog.categoryName}
</p>
) : null}
<div className="reveal relative z-10">
<nav
aria-label="مسیر صفحه"
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
>
<Link href="/blog" className="link-hover">
مقالات
</Link>
{category ? (
<>
<span aria-hidden className="text-line">
/
</span>
<span className="latin uppercase tracking-[0.14em]">
{category}
</span>
</>
) : null}
<span aria-hidden className="text-line">
/
</span>
<span className="line-clamp-1 text-ink-soft">{blog.title}</span>
</nav>
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{blog.title}
</h1>
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{blog.title}
</h1>
{blog.abstract ? (
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
{blog.abstract}
</p>
) : null}
<div className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
{date ? <time dateTime={blog.publishedAt ?? undefined}>{date}</time> : null}
{author ? <span>نویسنده: {author}</span> : null}
{blog.abstract ? (
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
{blog.abstract}
</p>
) : null}
</div>
</div>
</div>
{cover && blog.titleImageUrl ? (
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
<img
src={cover}
alt=""
className="mx-auto max-h-[28rem] w-full object-contain"
/>
<div className="mx-auto max-w-3xl px-5 lg:px-8">
{cover ? (
<div className="mb-5 overflow-hidden rounded-2xl">
<BlogCoverImage src={cover} className="mx-auto max-h-[28rem]" />
</div>
) : null}
{blog.mainTextHtml ? (
<div className="reveal reveal-delay-2">
<HtmlContent html={blog.mainTextHtml} />
{(date || author) ? (
<div className="mb-8 flex items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
{author ? <span>نویسنده: {author}</span> : <span />}
{date ? (
<time dateTime={blog.publishedAt ?? undefined}>{date}</time>
) : null}
</div>
) : cover ? (
<div className="mb-8 border-b border-line/70" />
) : null}
{bodyHtml ? (
<div className="reveal">
<HtmlContent html={bodyHtml} />
</div>
) : null}
</div>
+3 -8
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import { getBlogCoverImage, getBlogs } from "@/lib/api";
@@ -67,14 +68,8 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
}`}
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
{image ? (
<img
src={image}
alt=""
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
<div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} />
</div>
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
{post.title}
+178 -9
View File
@@ -16,6 +16,13 @@
--brand-gradient: linear-gradient(135deg, var(--accent-from), var(--accent-to));
--background: var(--paper);
--foreground: var(--ink);
--header-height: 9rem;
}
@media (min-width: 1024px) {
:root {
--header-height: 11rem;
}
}
@theme inline {
@@ -125,15 +132,45 @@ h4 {
}
.link-hover {
transition: color 0.25s ease;
display: inline-block;
width: fit-content;
max-width: 100%;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: inherit;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
}
.link-hover:hover {
color: var(--accent);
background: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* Keep flex layout when the link is already a flex row (nav + icon) */
a.link-hover.inline-flex,
.link-hover.inline-flex {
display: inline-flex;
}
/* Pill / button-style links: solid accent, no gradient fill */
a.link-hover.rounded-full,
.link-hover.rounded-full,
.link-hover.btn-primary {
background-image: none;
background-clip: unset;
-webkit-background-clip: unset;
-webkit-text-fill-color: unset;
}
a.link-hover.rounded-full:hover,
.link-hover.rounded-full:hover,
.link-hover.btn-primary:hover {
color: var(--accent);
-webkit-text-fill-color: var(--accent);
}
.border-hover {
@@ -165,11 +202,20 @@ a.group-border-hover:hover {
}
.group-text-hover {
transition: color 0.25s ease;
display: inline;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: inherit;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
}
.group:hover .group-text-hover {
color: var(--accent);
-webkit-text-fill-color: transparent;
color: transparent;
}
.carousel-mask {
@@ -207,6 +253,31 @@ a.group-border-hover:hover {
mask-image: radial-gradient(ellipse 70% 60% at 50% 30%, black 20%, transparent 75%);
}
.page-title-band {
border-bottom: 1px solid rgba(14, 17, 22, 0.08);
background-color: #fafafa;
background-image:
radial-gradient(rgba(14, 17, 22, 0.05) 1px, transparent 1px),
linear-gradient(rgba(14, 17, 22, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 17, 22, 0.02) 1px, transparent 1px);
background-size: 20px 20px, 40px 40px, 40px 40px;
}
.project-link-bar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 40;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background-color: rgba(14, 17, 22, 0.94);
background-image:
radial-gradient(rgba(255, 255, 255, 0.09) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px);
background-size: 18px 18px, 36px 36px, 36px 36px;
backdrop-filter: blur(10px);
}
.mesh-glow {
background:
radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%),
@@ -325,8 +396,19 @@ a.group-border-hover:hover {
.prose-content a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration: none;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
}
.prose-content a:hover {
-webkit-text-fill-color: transparent;
color: transparent;
}
.prose-content img {
@@ -364,3 +446,90 @@ a.group-border-hover:hover {
padding: 0.55rem 0.75rem;
text-align: start;
}
.footer-bottom-bar {
border-top: 1px solid #e5e5e5;
background: #f7f7f7;
}
.meshkee-credit {
position: relative;
}
.meshkee-credit__frame {
position: absolute;
inset: 0;
pointer-events: none;
}
.meshkee-credit__line {
position: absolute;
background: #c8c8c8;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit__line--l {
top: 0;
left: 0;
width: 1px;
height: 100%;
transform: scaleY(0);
transform-origin: top;
transition-delay: 0s;
}
.meshkee-credit__line--b {
bottom: 0;
left: 0;
width: 100%;
height: 1px;
transform: scaleX(0);
transform-origin: left;
transition-delay: 0.14s;
}
.meshkee-credit__line--r {
top: 0;
right: 0;
width: 1px;
height: 100%;
transform: scaleY(0);
transform-origin: bottom;
transition-delay: 0.28s;
}
.meshkee-credit__line--t {
top: 0;
left: 0;
width: 100%;
height: 1px;
transform: scaleX(0);
transform-origin: right;
transition-delay: 0.42s;
}
.meshkee-credit:hover .meshkee-credit__line--l,
.meshkee-credit:hover .meshkee-credit__line--r {
transform: scaleY(1);
}
.meshkee-credit:hover .meshkee-credit__line--t,
.meshkee-credit:hover .meshkee-credit__line--b {
transform: scaleX(1);
}
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
transition-delay: 0.42s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
transition-delay: 0.28s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
transition-delay: 0.14s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
transition-delay: 0s;
}
+73 -41
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent";
import { PortfolioGallery } from "@/components/PortfolioGallery";
import { PortfolioProjectLink } from "@/components/PortfolioProjectLink";
import {
categoryLabelFa,
findPortfolioCategory,
@@ -103,58 +104,82 @@ export default async function PortfolioDetailPage({
(item) => item.url !== cover,
);
const titleEn =
portfolio.titleEn ??
(portfolio.titleFa && portfolio.title !== portfolio.titleFa
? portfolio.title
: null);
const categoryHref = rootCategory
? portfolioCategoryHref(rootCategory.slug)
: "/portfolio";
return (
<ContentPageShell>
<ContentPageShell flushTop>
<article className="pb-20 lg:pb-28">
<div className="mx-auto max-w-5xl px-5 lg:px-8">
<div className="reveal mb-10 max-w-3xl">
<Link
href={
rootCategory
? portfolioCategoryHref(rootCategory.slug)
: "/portfolio"
}
className="link-hover mb-6 inline-flex text-sm text-muted"
>
بازگشت به نمونه‌کارها
</Link>
{categoryFa ? (
<p className="mb-3 text-[11px] text-muted">{categoryFa}</p>
) : null}
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{title}
</h1>
{portfolio.titleEn ? (
<p className="latin title-en text-muted">
{portfolio.titleEn}
</p>
) : null}
{portfolio.abstract ? (
<p className="mt-5 text-justify text-sm leading-8 text-muted md:text-base">
{portfolio.abstract}
</p>
) : null}
{date ? (
<time
className="mt-5 block text-xs text-muted"
dateTime={portfolio.publishedAt ?? undefined}
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]">
{titleEn ? (
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+2.6rem)]"
>
{date}
</time>
{titleEn}
</p>
) : null}
</div>
<div className="reveal relative z-10 max-w-3xl">
<nav
aria-label="مسیر صفحه"
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
>
<Link
href="/portfolio"
className="link-hover transition hover:text-ink"
>
نمونه‌کارها
</Link>
{rootCategory || categoryFa ? (
<>
<span aria-hidden className="text-line">
/
</span>
<Link
href={categoryHref}
className="link-hover transition hover:text-ink"
>
{rootCategory?.nameFa ?? categoryFa}
</Link>
</>
) : null}
<span aria-hidden className="text-line">
/
</span>
<span className="line-clamp-1 text-ink-soft">{title}</span>
</nav>
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{title}
</h1>
{portfolio.abstract ? (
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
{portfolio.abstract}
</p>
) : null}
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-5 lg:px-8">
<PortfolioGallery
cover={cover}
images={gallery.map((item) => ({
id: item.mediaId,
url: item.url,
}))}
category={categoryFa}
date={date}
dateTime={portfolio.publishedAt}
/>
{portfolio.mainTextHtml ? (
@@ -163,6 +188,13 @@ export default async function PortfolioDetailPage({
</div>
) : null}
</div>
{portfolio.projectUrl ? (
<PortfolioProjectLink
url={portfolio.projectUrl}
title={title}
/>
) : null}
</article>
</ContentPageShell>
);
+32 -30
View File
@@ -92,21 +92,28 @@ export default async function PortfolioListPage({
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
return (
<ContentPageShell>
<ContentPageShell flushTop>
<section className="pb-20 lg:pb-28">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 max-w-2xl md:mb-12">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
نمونه‌کارها
</h1>
<p className="latin title-en text-muted">
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-3 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-4 lg:pt-[calc(var(--header-height)+1rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.15rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.35rem)]"
>
Portfolio
</p>
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
نگاهی به پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.
</p>
<div className="reveal relative z-10 max-w-2xl">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal leading-tight tracking-tight text-ink">
نمونه‌کارها
</h1>
<p className="mt-1 text-sm leading-6 text-muted md:text-base">
نگاهی به پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.
</p>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
<Link
href="/portfolio"
@@ -143,7 +150,7 @@ export default async function PortfolioListPage({
) : (
<div
key={`${activeCategory?.slug ?? "all"}-${page}`}
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"
className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
>
{items.map((item, index) => {
const image = getPortfolioCoverImage(item);
@@ -162,13 +169,11 @@ export default async function PortfolioListPage({
<article
key={item.id}
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
index % 4 === 1
index % 3 === 1
? "reveal-delay-1"
: index % 4 === 2
: index % 3 === 2
? "reveal-delay-2"
: index % 4 === 3
? "reveal-delay-3"
: ""
: ""
}`}
>
<Link href={`/portfolio/${item.slug}`} className="block">
@@ -182,21 +187,18 @@ export default async function PortfolioListPage({
) : null}
</div>
<div className="px-4 py-4">
<div className="relative mb-2 min-h-[3.25rem] overflow-hidden">
{titleEn ? (
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-0 top-1/2 z-0 line-clamp-2 -translate-y-1/2 text-[clamp(1rem,2.2vw,1.35rem)] font-light uppercase leading-none tracking-[0.12em] text-ink/[0.07]"
>
{titleEn}
</p>
) : null}
<h2 className="group-text-hover relative z-10 line-clamp-2 whitespace-normal text-base font-normal leading-7 text-ink">
{titleFa}
</h2>
</div>
{categoryFa ? (
<p className="text-[11px] text-muted">{categoryFa}</p>
<p className="mb-2 text-[11px] text-muted">
{categoryFa}
</p>
) : null}
<h2 className="group-text-hover line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
{titleFa}
</h2>
{titleEn ? (
<p className="latin text-[12px] font-light uppercase tracking-[0.22em] text-ink-soft/70">
{titleEn}
</p>
) : null}
{item.abstract ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
type BlogCoverImageProps = {
src: string | null;
className?: string;
};
export function BlogCoverImage({ src, className = "" }: BlogCoverImageProps) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return (
<div
className={`flex min-h-[10rem] items-center justify-center rounded-2xl border border-line/60 bg-[linear-gradient(135deg,#f3f5f7_0%,#e8edf3_100%)] ${className}`.trim()}
>
<span className="latin text-[11px] tracking-[0.24em] text-muted uppercase">
Meshkee Blog
</span>
</div>
);
}
return (
<img
src={src}
alt=""
onError={() => setFailed(true)}
className={`h-auto w-full object-contain transition duration-700 group-hover:scale-105 ${className}`.trim()}
/>
);
}
+3 -8
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { getBlogCoverImage, getBlogs } from "@/lib/api";
export async function BlogSection() {
@@ -51,14 +52,8 @@ export async function BlogSection() {
}`}
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
{image ? (
<img
src={image}
alt=""
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
<div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} />
</div>
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
{post.title}
+3 -2
View File
@@ -4,14 +4,15 @@ import { RevealObserver } from "@/components/RevealObserver";
type ContentPageShellProps = {
children: React.ReactNode;
flushTop?: boolean;
};
export function ContentPageShell({ children }: ContentPageShellProps) {
export function ContentPageShell({ children, flushTop = false }: ContentPageShellProps) {
return (
<>
<RevealObserver />
<Header />
<main className="pt-36 lg:pt-44">{children}</main>
<main className={flushTop ? "" : "pt-36 lg:pt-44"}>{children}</main>
<Footer />
</>
);
+122 -18
View File
@@ -1,11 +1,50 @@
import Link from "next/link";
import { images } from "@/data/assets";
import { nav, site } from "@/data/home";
import {
portfolioCategoryHref,
topPortfolioCategories,
} from "@/data/portfolioCategories";
const stats = [
{ value: "112+", label: "بیش از ۱۱۲ وبسایت", labelEn: "WEBSITES" },
{ value: "28+", label: "بیش از ۲۸ لوگو", labelEn: "LOGOS" },
{ value: "7+", label: "بیش از ۷ اپلیکیشن", labelEn: "APPS" },
];
export function Footer() {
return (
<footer className="border-t border-line/70 bg-surface">
<div className="mx-auto flex max-w-7xl flex-col gap-10 px-5 py-12 lg:flex-row lg:items-start lg:justify-between lg:px-8">
<div>
<footer className="border-t border-line/70 bg-[linear-gradient(180deg,#fafafa_0%,#f3f5f7_100%)]">
<div className="border-b border-line/60">
<div className="mx-auto grid max-w-7xl grid-cols-1 divide-y divide-line/60 sm:grid-cols-3 sm:divide-x sm:divide-y-0 sm:divide-line/60">
{stats.map((stat) => (
<div
key={stat.label}
className="flex items-center gap-4 px-5 py-7 lg:gap-5 lg:px-8 lg:py-9"
>
<p
className="latin shrink-0 text-[clamp(2rem,4vw,2.75rem)] font-light leading-none tracking-tight text-ink"
dir="ltr"
>
{stat.value}
</p>
<span
aria-hidden
className="h-10 w-px shrink-0 bg-line/80 sm:h-12"
/>
<div className="min-w-0">
<p className="text-sm leading-6 text-ink-soft">{stat.label}</p>
<p className="latin mt-1 text-[10px] tracking-[0.22em] text-muted uppercase">
{stat.labelEn}
</p>
</div>
</div>
))}
</div>
</div>
<div className="mx-auto grid max-w-7xl gap-10 px-5 py-12 sm:grid-cols-2 lg:grid-cols-4 lg:gap-8 lg:px-8 lg:py-14">
<div className="sm:col-span-2 lg:col-span-1">
<p className="mb-2 text-2xl font-black text-ink">{site.nameFa}</p>
<p className="latin text-xs uppercase tracking-[0.18em] text-muted">
{site.taglineEn}
@@ -15,30 +54,95 @@ export function Footer() {
</p>
</div>
<div className="flex flex-col gap-3 text-sm text-ink-soft">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="transition link-hover"
>
{item.label}
</Link>
))}
<div>
<p className="mb-4 text-xs text-muted">خدمات</p>
<div className="flex flex-col gap-2.5 text-sm text-ink-soft">
{nav
.filter((item) => item.href !== "/portfolio")
.map((item) => (
<Link
key={item.href}
href={item.href}
className="link-hover transition"
>
{item.label}
</Link>
))}
</div>
</div>
<div className="text-sm text-muted" dir="ltr">
<a href={site.phoneHref} className="block transition hover:text-ink">
<div>
<p className="mb-4 text-xs text-muted">نمونه‌کارها</p>
<div className="flex flex-col gap-2.5 text-sm text-ink-soft">
<Link href="/portfolio" className="link-hover transition">
همه نمونه‌کارها
</Link>
{topPortfolioCategories.map((category) => (
<Link
key={category.id}
href={portfolioCategoryHref(category.slug)}
className="link-hover transition"
>
{category.nameFa}
</Link>
))}
</div>
</div>
<div>
<p className="mb-4 text-xs text-muted">تماس</p>
<a
href={site.phoneHref}
className="latin block text-sm text-ink-soft transition hover:text-ink"
dir="ltr"
>
{site.phone}
</a>
<p className="mt-2 max-w-xs text-right" dir="rtl">
<p className="mt-3 max-w-[16rem] text-sm leading-7 text-muted">
{site.address}
</p>
<Link
href="#contact"
className="mt-5 inline-flex rounded-full border border-line px-4 py-2 text-xs text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
تماس با ما
</Link>
</div>
</div>
<div className="border-t border-line/70 py-5 text-center text-xs text-muted">
© 2026 Meshkee — مشکی
<div className="footer-bottom-bar">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-5 py-4 sm:flex-row lg:px-8">
<div className="text-xs text-[#999]">
© {new Date().getFullYear()} Meshkee — مشکی
</div>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit group inline-flex items-center gap-2.5 px-2.5 py-1.5 text-[#999] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:text-[#555]"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<img
src={images.meshkeeCreditLogo}
alt="Meshkee"
width={36}
height={32}
className="relative h-8 w-auto shrink-0 object-contain opacity-50 transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:opacity-100"
/>
<span className="relative flex flex-col leading-snug">
<span className="text-[11px]">Designed And Developed By</span>
<span className="text-[13px] font-semibold text-[#666] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:text-[#333]">
Meshkee E-Commerce Team
</span>
</span>
</a>
</div>
</div>
</footer>
);
+6 -6
View File
@@ -18,7 +18,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
return (
<Link
href={item.href}
className="whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft transition hover:text-ink"
className="link-hover whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft"
>
{item.label}
</Link>
@@ -29,7 +29,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
<div className="group relative">
<Link
href={item.href}
className="inline-flex items-center gap-1 whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft transition hover:text-ink"
className="link-hover inline-flex items-center gap-1 whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft"
>
{item.label}
<svg
@@ -71,7 +71,7 @@ export function Header() {
const [expanded, setExpanded] = useState<string | null>(null);
return (
<header className="absolute inset-x-0 top-0 z-50">
<header className="fixed inset-x-0 top-0 z-50">
{/* Top bar: social icons + quick links */}
<div className="border-b border-line/50 bg-white/80 backdrop-blur-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-5 py-2 lg:px-8">
@@ -98,15 +98,15 @@ export function Header() {
</div>
<div className="hidden items-center gap-4 md:flex">
<Link href="/about" className="text-xs text-muted transition hover:text-ink">
<Link href="/about" className="link-hover text-xs text-muted">
درباره ما
</Link>
<Link href="#contact" className="text-xs text-muted transition hover:text-ink">
<Link href="#contact" className="link-hover text-xs text-muted">
تماس با ما
</Link>
<a
href={site.phoneHref}
className="latin text-xs text-muted transition hover:text-ink"
className="latin link-hover text-xs text-muted"
dir="ltr"
>
{site.phone}
+78 -28
View File
@@ -10,20 +10,36 @@ type GalleryImage = {
type PortfolioGalleryProps = {
cover?: string | null;
images: GalleryImage[];
category?: string | null;
date?: string | null;
dateTime?: string | null;
};
export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
export function PortfolioGallery({
cover,
images,
category,
date,
dateTime,
}: PortfolioGalleryProps) {
const slides: GalleryImage[] = [
...(cover ? [{ id: "cover", url: cover }] : []),
...images.filter((image) => image.url !== cover),
];
const thumbs = slides.filter((image) => image.id !== "cover");
const showMeta = Boolean(category || date);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const close = useEffectEvent(() => setActiveIndex(null));
const showPrev = useEffectEvent(() => {
setActiveIndex((current) =>
current == null ? current : (current - 1 + images.length) % images.length,
current == null ? current : (current - 1 + slides.length) % slides.length,
);
});
const showNext = useEffectEvent(() => {
setActiveIndex((current) =>
current == null ? current : (current + 1) % images.length,
current == null ? current : (current + 1) % slides.length,
);
});
@@ -47,42 +63,73 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
}, [activeIndex]);
const activeImage =
activeIndex == null ? null : images[activeIndex] ?? null;
activeIndex == null ? null : (slides[activeIndex] ?? null);
return (
<>
{cover ? (
<div className="reveal reveal-delay-1 mb-10 flex justify-center">
<img
src={cover}
alt=""
className="h-auto max-h-[28rem] w-auto max-w-full rounded-2xl object-contain"
/>
<div className="reveal reveal-delay-1 mb-4 flex justify-center">
<button
type="button"
onClick={() => setActiveIndex(0)}
className="group relative max-w-full overflow-hidden rounded-2xl text-start"
aria-label="باز کردن تصویر در لایت‌باکس"
>
<img
src={cover}
alt=""
className="h-auto max-h-[28rem] w-auto max-w-full object-contain transition duration-500 group-hover:scale-[1.01]"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink/55 to-transparent px-4 py-3 text-xs text-white opacity-0 transition group-hover:opacity-100">
بزرگ‌نمایی
</span>
</button>
</div>
) : null}
{images.length > 0 ? (
{showMeta ? (
<div className="reveal mb-5 flex items-center justify-between gap-4 text-xs text-muted">
<p className="text-ink-soft">{category}</p>
{date ? (
<time dateTime={dateTime ?? undefined} className="shrink-0">
{date}
</time>
) : (
<span />
)}
</div>
) : null}
{(showMeta || thumbs.length > 0) && (cover || showMeta) ? (
<div className="mb-8 border-t border-line/70" />
) : null}
{thumbs.length > 0 ? (
<div className="reveal reveal-delay-2 mb-12 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{images.map((image, index) => (
<button
key={image.id}
type="button"
onClick={() => setActiveIndex(index)}
className="group overflow-hidden rounded-lg border border-line/70 bg-paper-deep text-start transition hover:border-ink/25"
>
<img
src={image.url}
alt=""
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/>
</button>
))}
{thumbs.map((image) => {
const index = slides.findIndex((slide) => slide.id === image.id);
return (
<button
key={image.id}
type="button"
onClick={() => setActiveIndex(index)}
className="group overflow-hidden rounded-lg border border-line/70 bg-paper-deep text-start transition hover:border-ink/25"
>
<img
src={image.url}
alt=""
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/>
</button>
);
})}
</div>
) : null}
{activeImage ? (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/85 p-4 backdrop-blur-sm"
className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/90 p-4 backdrop-blur-md"
role="dialog"
aria-modal="true"
aria-label="گالری تصاویر"
@@ -96,7 +143,7 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
بستن
</button>
{images.length > 1 ? (
{slides.length > 1 ? (
<>
<button
type="button"
@@ -120,13 +167,16 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
>
›
</button>
<p className="latin absolute bottom-5 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white/80">
{(activeIndex ?? 0) + 1} / {slides.length}
</p>
</>
) : null}
<img
src={activeImage.url}
alt=""
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain"
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain shadow-[0_24px_80px_rgba(0,0,0,0.35)]"
onClick={(event) => event.stopPropagation()}
/>
</div>
+49
View File
@@ -0,0 +1,49 @@
type PortfolioProjectLinkProps = {
url: string;
title?: string;
};
function displayHost(url: string): string {
try {
return new URL(url).host.replace(/^www\./, "");
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
}
export function PortfolioProjectLink({
url,
title,
}: PortfolioProjectLinkProps) {
const host = displayHost(url);
return (
<>
<div aria-hidden className="h-20 sm:h-16" />
<section className="project-link-bar text-surface">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="group mx-auto flex max-w-7xl flex-col gap-2 px-5 py-3 transition sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:px-8 lg:py-3.5"
>
<div className="min-w-0">
<p className="text-base font-normal text-white sm:text-lg">
{title ? `بازدید از ${title}` : "باز کردن وبسایت پروژه"}
</p>
<p className="latin mt-1 text-xs tracking-[0.14em] text-white/45 uppercase">
{host}
</p>
</div>
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full border border-white/20 bg-white/5 px-4 py-2.5 text-sm text-white transition group-hover:border-white/40 group-hover:bg-white/10 sm:self-auto">
رفتن به سایت
<span aria-hidden className="latin text-base leading-none">
↗
</span>
</span>
</a>
</section>
</>
);
}
+21 -4
View File
@@ -3,28 +3,45 @@
import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
function revealElement(el: Element) {
el.classList.add("is-visible");
}
function RevealObserverInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.toString();
useEffect(() => {
const elements = document.querySelectorAll(".reveal:not(.is-visible)");
const elements = Array.from(
document.querySelectorAll(".reveal:not(.is-visible)"),
);
if (!elements.length) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
revealElement(entry.target);
observer.unobserve(entry.target);
}
}
},
{ threshold: 0.16, rootMargin: "0px 0px -8% 0px" },
{ threshold: 0.01, rootMargin: "0px 0px -4% 0px" },
);
elements.forEach((el) => observer.observe(el));
for (const el of elements) {
const rect = el.getBoundingClientRect();
const inView =
rect.top < window.innerHeight * 0.95 && rect.bottom > 0;
if (inView) {
revealElement(el);
} else {
observer.observe(el);
}
}
return () => observer.disconnect();
}, [pathname, search]);
+1 -1
View File
@@ -36,7 +36,7 @@ export function Services() {
className="h-12 w-12 shrink-0 object-contain"
/>
<div className="min-w-0">
<h3 className="text-[0.9rem] font-normal leading-snug text-ink md:text-[0.95rem]">
<h3 className="group-text-hover text-[0.9rem] font-normal leading-snug text-ink md:text-[0.95rem]">
{service.title}
</h3>
<p className="latin title-en-sm text-muted">
+1
View File
@@ -9,6 +9,7 @@ export const images = {
brandLogoWhite: "/images/brand/logo-white.png",
brandMark: "/images/brand/mark-black.png",
brandMarkWhite: "/images/brand/mark-white.png",
meshkeeCreditLogo: "/images/brand/MeshkeeLogo.png",
services: {
web: `${CDN}/files/ecXmIxV0te.png`,
app: `${CDN}/files/L3esyJkRFC.png`,
+1 -1
View File
@@ -13,7 +13,7 @@ export const site = {
phone: "0902 4200 240",
phoneHref: "tel:+989024200240",
email: "info@meshkee.com",
address: "قم، بلوار امام رضا، مجتمع فردوس، طبقهٔ اول تجاری، واحد ۳۰۹ و ۳۱۰",
address: "قم، نبش کریمی ۳۴، ساختمان ظفر، طبقهٔ چهارم",
};
export type NavItem = {
+20 -2
View File
@@ -41,6 +41,7 @@ export type Portfolio = {
titleEn?: string | null;
slug: string;
abstract: string | null;
projectUrl?: string | null;
titleImageUrl: string | null;
mainTextHtml: string | null;
publishedAt: string | null;
@@ -245,8 +246,25 @@ export function getBlogCoverImage(
): string | null {
if (blog.titleImageUrl) return blog.titleImageUrl;
const match = blog.mainTextHtml?.match(/<img[^>]+src=["']([^"']+)["']/i);
return match?.[1] ?? null;
const urls = extractImageUrlsFromHtml(blog.mainTextHtml);
const preferred = urls.find((url) => url.includes("/meshkee/businesses/"));
return preferred ?? urls[0] ?? null;
}
function extractImageUrlsFromHtml(html: string | null | undefined): string[] {
if (!html) return [];
const urls: string[] = [];
const pattern = /<img[^>]+src=["']([^"']+)["']/gi;
let match = pattern.exec(html);
while (match) {
urls.push(match[1]);
match = pattern.exec(html);
}
return urls;
}
export function getPortfolioCoverImage(