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 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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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 { 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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { 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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user