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:
co-authored by
Cursor
parent
37d8e4be91
commit
908d86939c
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { BlogCoverImage } from "@/components/BlogCoverImage";
|
||||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||||
import { HtmlContent } from "@/components/HtmlContent";
|
import { HtmlContent } from "@/components/HtmlContent";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,28 @@ type BlogDetailPageProps = {
|
|||||||
params: Promise<{ slug: string }>;
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: BlogDetailPageProps): Promise<Metadata> {
|
}: BlogDetailPageProps): Promise<Metadata> {
|
||||||
@@ -45,54 +68,82 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
|||||||
const cover = getBlogCoverImage(blog);
|
const cover = getBlogCoverImage(blog);
|
||||||
const date = formatFaDate(blog.publishedAt);
|
const date = formatFaDate(blog.publishedAt);
|
||||||
const author = authorName(blog.author);
|
const author = authorName(blog.author);
|
||||||
|
const category = blog.categoryName;
|
||||||
|
const bodyHtml = blog.mainTextHtml
|
||||||
|
? stripLeadingCoverImage(blog.mainTextHtml, cover)
|
||||||
|
: null;
|
||||||
|
const titleEn = category || "Blog";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentPageShell>
|
<ContentPageShell flushTop>
|
||||||
<article className="pb-20 lg:pb-28">
|
<article className="pb-20 lg:pb-28">
|
||||||
<div className="mx-auto max-w-3xl px-5 lg:px-8">
|
<div className="page-title-band mb-8 md:mb-10">
|
||||||
<div className="reveal mb-8">
|
<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)]">
|
||||||
<Link
|
<p
|
||||||
href="/blog"
|
aria-hidden
|
||||||
className="link-hover mb-6 inline-flex text-sm text-muted"
|
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)]"
|
||||||
>
|
>
|
||||||
بازگشت به مقالات
|
{titleEn}
|
||||||
</Link>
|
|
||||||
|
|
||||||
{blog.categoryName ? (
|
|
||||||
<p className="latin title-en mb-3 text-muted">
|
|
||||||
{blog.categoryName}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<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}
|
) : 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">
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
||||||
{blog.title}
|
{blog.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{blog.abstract ? (
|
{blog.abstract ? (
|
||||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
|
||||||
{blog.abstract}
|
{blog.abstract}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cover && blog.titleImageUrl ? (
|
<div className="mx-auto max-w-3xl px-5 lg:px-8">
|
||||||
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
|
{cover ? (
|
||||||
<img
|
<div className="mb-5 overflow-hidden rounded-2xl">
|
||||||
src={cover}
|
<BlogCoverImage src={cover} className="mx-auto max-h-[28rem]" />
|
||||||
alt=""
|
|
||||||
className="mx-auto max-h-[28rem] w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{blog.mainTextHtml ? (
|
{(date || author) ? (
|
||||||
<div className="reveal reveal-delay-2">
|
<div className="mb-8 flex items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
|
||||||
<HtmlContent html={blog.mainTextHtml} />
|
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { BlogCoverImage } from "@/components/BlogCoverImage";
|
||||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
||||||
@@ -67,14 +68,8 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={`/blog/${post.slug}`} className="block">
|
<Link href={`/blog/${post.slug}`} className="block">
|
||||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
<div className="relative mb-4 overflow-hidden rounded-2xl">
|
||||||
{image ? (
|
<BlogCoverImage src={image} />
|
||||||
<img
|
|
||||||
src={image}
|
|
||||||
alt=""
|
|
||||||
className="h-auto w-full transition duration-700 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
|
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
|
||||||
{post.title}
|
{post.title}
|
||||||
|
|||||||
+178
-9
@@ -16,6 +16,13 @@
|
|||||||
--brand-gradient: linear-gradient(135deg, var(--accent-from), var(--accent-to));
|
--brand-gradient: linear-gradient(135deg, var(--accent-from), var(--accent-to));
|
||||||
--background: var(--paper);
|
--background: var(--paper);
|
||||||
--foreground: var(--ink);
|
--foreground: var(--ink);
|
||||||
|
--header-height: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
:root {
|
||||||
|
--header-height: 11rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -125,15 +132,45 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-hover {
|
.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 {
|
.link-hover:hover {
|
||||||
color: var(--accent);
|
|
||||||
background: var(--brand-gradient);
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
-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 {
|
.border-hover {
|
||||||
@@ -165,11 +202,20 @@ a.group-border-hover:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-text-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 {
|
.group:hover .group-text-hover {
|
||||||
color: var(--accent);
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-mask {
|
.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%);
|
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 {
|
.mesh-glow {
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%),
|
radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%),
|
||||||
@@ -325,8 +396,19 @@ a.group-border-hover:hover {
|
|||||||
|
|
||||||
.prose-content a {
|
.prose-content a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
text-underline-offset: 3px;
|
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 {
|
.prose-content img {
|
||||||
@@ -364,3 +446,90 @@ a.group-border-hover:hover {
|
|||||||
padding: 0.55rem 0.75rem;
|
padding: 0.55rem 0.75rem;
|
||||||
text-align: start;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||||
import { HtmlContent } from "@/components/HtmlContent";
|
import { HtmlContent } from "@/components/HtmlContent";
|
||||||
import { PortfolioGallery } from "@/components/PortfolioGallery";
|
import { PortfolioGallery } from "@/components/PortfolioGallery";
|
||||||
|
import { PortfolioProjectLink } from "@/components/PortfolioProjectLink";
|
||||||
import {
|
import {
|
||||||
categoryLabelFa,
|
categoryLabelFa,
|
||||||
findPortfolioCategory,
|
findPortfolioCategory,
|
||||||
@@ -103,58 +104,82 @@ export default async function PortfolioDetailPage({
|
|||||||
(item) => item.url !== cover,
|
(item) => item.url !== cover,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const titleEn =
|
||||||
<ContentPageShell>
|
portfolio.titleEn ??
|
||||||
<article className="pb-20 lg:pb-28">
|
(portfolio.titleFa && portfolio.title !== portfolio.titleFa
|
||||||
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
? portfolio.title
|
||||||
<div className="reveal mb-10 max-w-3xl">
|
: null);
|
||||||
<Link
|
const categoryHref = rootCategory
|
||||||
href={
|
|
||||||
rootCategory
|
|
||||||
? portfolioCategoryHref(rootCategory.slug)
|
? portfolioCategoryHref(rootCategory.slug)
|
||||||
: "/portfolio"
|
: "/portfolio";
|
||||||
}
|
|
||||||
className="link-hover mb-6 inline-flex text-sm text-muted"
|
|
||||||
>
|
|
||||||
بازگشت به نمونهکارها
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{categoryFa ? (
|
return (
|
||||||
<p className="mb-3 text-[11px] text-muted">{categoryFa}</p>
|
<ContentPageShell flushTop>
|
||||||
|
<article className="pb-20 lg:pb-28">
|
||||||
|
<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)]"
|
||||||
|
>
|
||||||
|
{titleEn}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<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">
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{portfolio.titleEn ? (
|
|
||||||
<p className="latin title-en text-muted">
|
|
||||||
{portfolio.titleEn}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{portfolio.abstract ? (
|
{portfolio.abstract ? (
|
||||||
<p className="mt-5 text-justify text-sm leading-8 text-muted md:text-base">
|
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
|
||||||
{portfolio.abstract}
|
{portfolio.abstract}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
{date ? (
|
</div>
|
||||||
<time
|
|
||||||
className="mt-5 block text-xs text-muted"
|
|
||||||
dateTime={portfolio.publishedAt ?? undefined}
|
|
||||||
>
|
|
||||||
{date}
|
|
||||||
</time>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
||||||
<PortfolioGallery
|
<PortfolioGallery
|
||||||
cover={cover}
|
cover={cover}
|
||||||
images={gallery.map((item) => ({
|
images={gallery.map((item) => ({
|
||||||
id: item.mediaId,
|
id: item.mediaId,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
}))}
|
}))}
|
||||||
|
category={categoryFa}
|
||||||
|
date={date}
|
||||||
|
dateTime={portfolio.publishedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{portfolio.mainTextHtml ? (
|
{portfolio.mainTextHtml ? (
|
||||||
@@ -163,6 +188,13 @@ export default async function PortfolioDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{portfolio.projectUrl ? (
|
||||||
|
<PortfolioProjectLink
|
||||||
|
url={portfolio.projectUrl}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
</ContentPageShell>
|
</ContentPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
+26
-24
@@ -92,21 +92,28 @@ export default async function PortfolioListPage({
|
|||||||
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
|
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentPageShell>
|
<ContentPageShell flushTop>
|
||||||
<section className="pb-20 lg:pb-28">
|
<section className="pb-20 lg:pb-28">
|
||||||
<div className="mx-auto max-w-7xl px-5 lg:px-8">
|
<div className="page-title-band mb-8 md:mb-10">
|
||||||
<div className="reveal mb-10 max-w-2xl md:mb-12">
|
<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)]">
|
||||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
<p
|
||||||
نمونهکارها
|
aria-hidden
|
||||||
</h1>
|
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)]"
|
||||||
<p className="latin title-en text-muted">
|
>
|
||||||
Portfolio
|
Portfolio
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
|
||||||
<Link
|
<Link
|
||||||
href="/portfolio"
|
href="/portfolio"
|
||||||
@@ -143,7 +150,7 @@ export default async function PortfolioListPage({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={`${activeCategory?.slug ?? "all"}-${page}`}
|
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) => {
|
{items.map((item, index) => {
|
||||||
const image = getPortfolioCoverImage(item);
|
const image = getPortfolioCoverImage(item);
|
||||||
@@ -162,12 +169,10 @@ export default async function PortfolioListPage({
|
|||||||
<article
|
<article
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
|
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
|
||||||
index % 4 === 1
|
index % 3 === 1
|
||||||
? "reveal-delay-1"
|
? "reveal-delay-1"
|
||||||
: index % 4 === 2
|
: index % 3 === 2
|
||||||
? "reveal-delay-2"
|
? "reveal-delay-2"
|
||||||
: index % 4 === 3
|
|
||||||
? "reveal-delay-3"
|
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -182,21 +187,18 @@ export default async function PortfolioListPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
<div className="relative mb-2 min-h-[3.25rem] overflow-hidden">
|
{categoryFa ? (
|
||||||
{titleEn ? (
|
<p className="mb-2 text-[11px] text-muted">
|
||||||
<p
|
{categoryFa}
|
||||||
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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="group-text-hover relative z-10 line-clamp-2 whitespace-normal text-base font-normal leading-7 text-ink">
|
<h2 className="group-text-hover line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
|
||||||
{titleFa}
|
{titleFa}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
{titleEn ? (
|
||||||
{categoryFa ? (
|
<p className="latin text-[12px] font-light uppercase tracking-[0.22em] text-ink-soft/70">
|
||||||
<p className="text-[11px] text-muted">{categoryFa}</p>
|
{titleEn}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{item.abstract ? (
|
{item.abstract ? (
|
||||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
|
<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 |
@@ -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()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { BlogCoverImage } from "@/components/BlogCoverImage";
|
||||||
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
||||||
|
|
||||||
export async function BlogSection() {
|
export async function BlogSection() {
|
||||||
@@ -51,14 +52,8 @@ export async function BlogSection() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={`/blog/${post.slug}`} className="block">
|
<Link href={`/blog/${post.slug}`} className="block">
|
||||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
<div className="relative mb-4 overflow-hidden rounded-2xl">
|
||||||
{image ? (
|
<BlogCoverImage src={image} />
|
||||||
<img
|
|
||||||
src={image}
|
|
||||||
alt=""
|
|
||||||
className="h-auto w-full transition duration-700 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
|
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
|
||||||
{post.title}
|
{post.title}
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { RevealObserver } from "@/components/RevealObserver";
|
|||||||
|
|
||||||
type ContentPageShellProps = {
|
type ContentPageShellProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
flushTop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContentPageShell({ children }: ContentPageShellProps) {
|
export function ContentPageShell({ children, flushTop = false }: ContentPageShellProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RevealObserver />
|
<RevealObserver />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="pt-36 lg:pt-44">{children}</main>
|
<main className={flushTop ? "" : "pt-36 lg:pt-44"}>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+115
-11
@@ -1,11 +1,50 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { images } from "@/data/assets";
|
||||||
import { nav, site } from "@/data/home";
|
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() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-line/70 bg-surface">
|
<footer className="border-t border-line/70 bg-[linear-gradient(180deg,#fafafa_0%,#f3f5f7_100%)]">
|
||||||
<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 className="border-b border-line/60">
|
||||||
<div>
|
<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="mb-2 text-2xl font-black text-ink">{site.nameFa}</p>
|
||||||
<p className="latin text-xs uppercase tracking-[0.18em] text-muted">
|
<p className="latin text-xs uppercase tracking-[0.18em] text-muted">
|
||||||
{site.taglineEn}
|
{site.taglineEn}
|
||||||
@@ -15,30 +54,95 @@ export function Footer() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 text-sm text-ink-soft">
|
<div>
|
||||||
{nav.map((item) => (
|
<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
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="transition link-hover"
|
className="link-hover transition"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted" dir="ltr">
|
<div>
|
||||||
<a href={site.phoneHref} className="block transition hover:text-ink">
|
<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}
|
{site.phone}
|
||||||
</a>
|
</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}
|
{site.address}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-line/70 py-5 text-center text-xs text-muted">
|
<div className="footer-bottom-bar">
|
||||||
© 2026 Meshkee — مشکی
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
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}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -29,7 +29,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
|
|||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
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}
|
{item.label}
|
||||||
<svg
|
<svg
|
||||||
@@ -71,7 +71,7 @@ export function Header() {
|
|||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Top bar: social icons + quick links */}
|
||||||
<div className="border-b border-line/50 bg-white/80 backdrop-blur-sm">
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="hidden items-center gap-4 md:flex">
|
<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>
|
||||||
<Link href="#contact" className="text-xs text-muted transition hover:text-ink">
|
<Link href="#contact" className="link-hover text-xs text-muted">
|
||||||
تماس با ما
|
تماس با ما
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={site.phoneHref}
|
href={site.phoneHref}
|
||||||
className="latin text-xs text-muted transition hover:text-ink"
|
className="latin link-hover text-xs text-muted"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
{site.phone}
|
{site.phone}
|
||||||
|
|||||||
@@ -10,20 +10,36 @@ type GalleryImage = {
|
|||||||
type PortfolioGalleryProps = {
|
type PortfolioGalleryProps = {
|
||||||
cover?: string | null;
|
cover?: string | null;
|
||||||
images: GalleryImage[];
|
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 [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const close = useEffectEvent(() => setActiveIndex(null));
|
const close = useEffectEvent(() => setActiveIndex(null));
|
||||||
const showPrev = useEffectEvent(() => {
|
const showPrev = useEffectEvent(() => {
|
||||||
setActiveIndex((current) =>
|
setActiveIndex((current) =>
|
||||||
current == null ? current : (current - 1 + images.length) % images.length,
|
current == null ? current : (current - 1 + slides.length) % slides.length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const showNext = useEffectEvent(() => {
|
const showNext = useEffectEvent(() => {
|
||||||
setActiveIndex((current) =>
|
setActiveIndex((current) =>
|
||||||
current == null ? current : (current + 1) % images.length,
|
current == null ? current : (current + 1) % slides.length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,23 +63,53 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
|||||||
}, [activeIndex]);
|
}, [activeIndex]);
|
||||||
|
|
||||||
const activeImage =
|
const activeImage =
|
||||||
activeIndex == null ? null : images[activeIndex] ?? null;
|
activeIndex == null ? null : (slides[activeIndex] ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cover ? (
|
{cover ? (
|
||||||
<div className="reveal reveal-delay-1 mb-10 flex justify-center">
|
<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
|
<img
|
||||||
src={cover}
|
src={cover}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-auto max-h-[28rem] w-auto max-w-full rounded-2xl object-contain"
|
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>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<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) => (
|
{thumbs.map((image) => {
|
||||||
|
const index = slides.findIndex((slide) => slide.id === image.id);
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -76,13 +122,14 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
|||||||
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
|
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeImage ? (
|
{activeImage ? (
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="گالری تصاویر"
|
aria-label="گالری تصاویر"
|
||||||
@@ -96,7 +143,7 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
|||||||
بستن
|
بستن
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{slides.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,13 +167,16 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
|||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</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}
|
) : null}
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={activeImage.url}
|
src={activeImage.url}
|
||||||
alt=""
|
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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,28 +3,45 @@
|
|||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
function revealElement(el: Element) {
|
||||||
|
el.classList.add("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
function RevealObserverInner() {
|
function RevealObserverInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const search = searchParams.toString();
|
const search = searchParams.toString();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const elements = document.querySelectorAll(".reveal:not(.is-visible)");
|
const elements = Array.from(
|
||||||
|
document.querySelectorAll(".reveal:not(.is-visible)"),
|
||||||
|
);
|
||||||
if (!elements.length) return;
|
if (!elements.length) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
entry.target.classList.add("is-visible");
|
revealElement(entry.target);
|
||||||
observer.unobserve(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();
|
return () => observer.disconnect();
|
||||||
}, [pathname, search]);
|
}, [pathname, search]);
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function Services() {
|
|||||||
className="h-12 w-12 shrink-0 object-contain"
|
className="h-12 w-12 shrink-0 object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<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}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="latin title-en-sm text-muted">
|
<p className="latin title-en-sm text-muted">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const images = {
|
|||||||
brandLogoWhite: "/images/brand/logo-white.png",
|
brandLogoWhite: "/images/brand/logo-white.png",
|
||||||
brandMark: "/images/brand/mark-black.png",
|
brandMark: "/images/brand/mark-black.png",
|
||||||
brandMarkWhite: "/images/brand/mark-white.png",
|
brandMarkWhite: "/images/brand/mark-white.png",
|
||||||
|
meshkeeCreditLogo: "/images/brand/MeshkeeLogo.png",
|
||||||
services: {
|
services: {
|
||||||
web: `${CDN}/files/ecXmIxV0te.png`,
|
web: `${CDN}/files/ecXmIxV0te.png`,
|
||||||
app: `${CDN}/files/L3esyJkRFC.png`,
|
app: `${CDN}/files/L3esyJkRFC.png`,
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export const site = {
|
|||||||
phone: "0902 4200 240",
|
phone: "0902 4200 240",
|
||||||
phoneHref: "tel:+989024200240",
|
phoneHref: "tel:+989024200240",
|
||||||
email: "info@meshkee.com",
|
email: "info@meshkee.com",
|
||||||
address: "قم، بلوار امام رضا، مجتمع فردوس، طبقهٔ اول تجاری، واحد ۳۰۹ و ۳۱۰",
|
address: "قم، نبش کریمی ۳۴، ساختمان ظفر، طبقهٔ چهارم",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
|
|||||||
+20
-2
@@ -41,6 +41,7 @@ export type Portfolio = {
|
|||||||
titleEn?: string | null;
|
titleEn?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
projectUrl?: string | null;
|
||||||
titleImageUrl: string | null;
|
titleImageUrl: string | null;
|
||||||
mainTextHtml: string | null;
|
mainTextHtml: string | null;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
@@ -245,8 +246,25 @@ export function getBlogCoverImage(
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (blog.titleImageUrl) return blog.titleImageUrl;
|
if (blog.titleImageUrl) return blog.titleImageUrl;
|
||||||
|
|
||||||
const match = blog.mainTextHtml?.match(/<img[^>]+src=["']([^"']+)["']/i);
|
const urls = extractImageUrlsFromHtml(blog.mainTextHtml);
|
||||||
return match?.[1] ?? null;
|
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(
|
export function getPortfolioCoverImage(
|
||||||
|
|||||||
Reference in New Issue
Block a user