phase 1-2 done
This commit is contained in:
9
src/app/[locale]/about/page.tsx
Normal file
9
src/app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-32">
|
||||
<span className="font-mono text-xs text-accent">// about</span>
|
||||
<h1 className="mt-4 text-4xl font-bold text-text-primary">درباره علی تقوی</h1>
|
||||
<p className="mt-4 text-text-secondary">در حال آمادهسازی — Phase 3</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/app/[locale]/contact/page.tsx
Normal file
9
src/app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-32">
|
||||
<span className="font-mono text-xs text-accent">// contact</span>
|
||||
<h1 className="mt-4 text-4xl font-bold text-text-primary">تماس</h1>
|
||||
<p className="mt-4 text-text-secondary">در حال آمادهسازی — Phase 3</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/app/[locale]/layout.tsx
Normal file
96
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { jetbrainsMono, plusJakartaSans } from "@/lib/fonts";
|
||||
import { locales, localeDir, type Locale } from "@/lib/i18n/config";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isRtl = locale === "fa";
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: `https://biztaghavi.com/${locale}`,
|
||||
languages: {
|
||||
fa: "https://biztaghavi.com/fa",
|
||||
en: "https://biztaghavi.com/en",
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"content-language": isRtl ? "fa-IR" : "en-US",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: LocaleLayoutProps) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const dir = localeDir[locale as Locale];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
name: "Ali Taghavi",
|
||||
alternateName: "علی تقوی",
|
||||
url: "https://biztaghavi.com",
|
||||
jobTitle: "CEO",
|
||||
worksFor: { "@type": "Organization", name: "NODE-Group" },
|
||||
sameAs: [
|
||||
"https://github.com/alitaghavi",
|
||||
"https://linkedin.com/in/alitaghavi",
|
||||
"https://twitter.com/alitaghavi",
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
className={cn(
|
||||
jetbrainsMono.variable,
|
||||
plusJakartaSans.variable,
|
||||
"h-full antialiased"
|
||||
)}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<link rel="alternate" type="application/rss+xml" title="علی تقوی — نوشتهها" href="/api/rss" />
|
||||
</head>
|
||||
<body className="relative flex min-h-full flex-col bg-background text-text-primary">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Header />
|
||||
<main className="flex-1 relative z-10">{children}</main>
|
||||
<Footer />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
22
src/app/[locale]/page.tsx
Normal file
22
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
import { Hero } from "@/components/home/Hero";
|
||||
import { BuildingNow } from "@/components/home/BuildingNow";
|
||||
import { LatestWriting } from "@/components/home/LatestWriting";
|
||||
import { SignalCTA } from "@/components/home/SignalCTA";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<AnimatedSection>
|
||||
<BuildingNow />
|
||||
</AnimatedSection>
|
||||
<AnimatedSection delay={0.1}>
|
||||
<LatestWriting />
|
||||
</AnimatedSection>
|
||||
<AnimatedSection delay={0.2}>
|
||||
<SignalCTA />
|
||||
</AnimatedSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
src/app/[locale]/work/[slug]/page.tsx
Normal file
150
src/app/[locale]/work/[slug]/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ExternalLink, Github, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { projectBySlugQuery, allProjectSlugsQuery } from "@/lib/sanity/queries";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import { PortableTextRenderer } from "@/components/writing/PortableTextRenderer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Project } from "@/lib/sanity/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await sanityFetch<{ slug: string }[]>(allProjectSlugsQuery);
|
||||
return (slugs ?? []).map((s) => ({ slug: s.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = await sanityFetch<Project>(projectBySlugQuery, { slug });
|
||||
if (!project) return {};
|
||||
return {
|
||||
title: project.seo?.title ?? project.title,
|
||||
description: project.seo?.description ?? project.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: Props) {
|
||||
const { locale, slug } = await params;
|
||||
const [project, t, tWork] = await Promise.all([
|
||||
sanityFetch<Project>(projectBySlugQuery, { slug }),
|
||||
getTranslations({ locale, namespace: "common" }),
|
||||
getTranslations({ locale, namespace: "work" }),
|
||||
]);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
const isRtl = locale === "fa";
|
||||
const BackArrow = isRtl ? ArrowRight : ArrowLeft;
|
||||
const coverUrl = project.coverImage?.asset
|
||||
? urlFor(project.coverImage).width(1400).height(600).url()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<article className="relative z-10 pt-24">
|
||||
<div className="mx-auto max-w-4xl px-6 py-4">
|
||||
<Link
|
||||
href="/work"
|
||||
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
<BackArrow size={14} />
|
||||
{t("back")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
{coverUrl && (
|
||||
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
|
||||
<Image src={coverUrl} alt={project.coverImage?.alt ?? project.title} fill className="object-cover" priority />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 pb-16">
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<Badge variant="secondary" className="mb-4">{project.projectType}</Badge>
|
||||
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{project.title}</h1>
|
||||
{project.description && (
|
||||
<p className="text-lg text-text-secondary leading-relaxed">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{project.liveUrl && (
|
||||
<Button asChild>
|
||||
<a href={project.liveUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={15} /> {t("live_demo")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<Button asChild variant="secondary">
|
||||
<a href={project.githubUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Github size={15} /> {t("source_code")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
{project.body && <PortableTextRenderer value={project.body as unknown[]} />}
|
||||
|
||||
{/* Tech stack + tools */}
|
||||
<div className="mt-10 grid gap-6 border-t border-border pt-8 sm:grid-cols-2">
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-xs text-accent">// {tWork("tech_stack")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.techStack.map((t) => (
|
||||
<span key={t} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{project.toolsUsed && project.toolsUsed.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-xs text-accent">// {tWork("tools")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.toolsUsed.map((tool) => (
|
||||
<span key={tool} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery */}
|
||||
{project.gallery && project.gallery.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3 className="mb-4 font-mono text-xs text-accent">// gallery</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{project.gallery.map((img, i) => (
|
||||
<div key={i} className="relative aspect-video overflow-hidden rounded-lg border border-border">
|
||||
<Image
|
||||
src={urlFor(img).width(800).url()}
|
||||
alt={img.alt ?? `Gallery ${i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/work/page.tsx
Normal file
35
src/app/[locale]/work/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { WorkListClient } from "@/components/work/WorkListClient";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { allProjectsQuery } from "@/lib/sanity/queries";
|
||||
import type { Project } from "@/lib/sanity/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "work" });
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
export default async function WorkPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "work" });
|
||||
|
||||
const projects = await sanityFetch<Project[]>(allProjectsQuery, { locale });
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="work" />
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</div>
|
||||
<WorkListClient projects={projects ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/app/[locale]/writing/[slug]/page.tsx
Normal file
169
src/app/[locale]/writing/[slug]/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowRight, ArrowLeft, Clock } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { postBySlugQuery, relatedPostsQuery, allPostSlugsQuery } from "@/lib/sanity/queries";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import { formatDate, estimateReadTime } from "@/lib/sanity/utils";
|
||||
import { PortableTextRenderer } from "@/components/writing/PortableTextRenderer";
|
||||
import { ReadingProgress } from "@/components/writing/ReadingProgress";
|
||||
import { ShareButtons } from "@/components/writing/ShareButtons";
|
||||
import { PostCard } from "@/components/writing/PostCard";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Post } from "@/lib/sanity/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await sanityFetch<{ slug: string; locale: string }[]>(allPostSlugsQuery);
|
||||
return (slugs ?? []).map((s) => ({ slug: s.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await sanityFetch<Post>(postBySlugQuery, { slug });
|
||||
if (!post) return {};
|
||||
return {
|
||||
title: post.seo?.title ?? post.title,
|
||||
description: post.seo?.description ?? post.excerpt,
|
||||
openGraph: {
|
||||
title: post.seo?.title ?? post.title,
|
||||
description: post.seo?.description ?? post.excerpt,
|
||||
images: post.seo?.ogImage?.url
|
||||
? [{ url: post.seo.ogImage.url }]
|
||||
: post.coverImage?.asset
|
||||
? [{ url: urlFor(post.coverImage).width(1200).height(630).url() }]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { locale, slug } = await params;
|
||||
const [post, t, tCommon, tCat] = await Promise.all([
|
||||
sanityFetch<Post>(postBySlugQuery, { slug }),
|
||||
getTranslations({ locale, namespace: "writing" }),
|
||||
getTranslations({ locale, namespace: "common" }),
|
||||
getTranslations({ locale, namespace: "writing.categories" }),
|
||||
]);
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
const related = await sanityFetch<Post[]>(relatedPostsQuery, {
|
||||
locale,
|
||||
category: post.category,
|
||||
slug,
|
||||
});
|
||||
|
||||
const readTime = post.body ? estimateReadTime(post.body as unknown[]) : null;
|
||||
const coverUrl = post.coverImage?.asset
|
||||
? urlFor(post.coverImage).width(1400).height(600).url()
|
||||
: null;
|
||||
const postUrl = `https://biztaghavi.com/${locale}/writing/${slug}`;
|
||||
const isRtl = locale === "fa";
|
||||
const BackArrow = isRtl ? ArrowRight : ArrowLeft;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
|
||||
<article className="relative z-10 pt-24">
|
||||
{/* Back link */}
|
||||
<div className="mx-auto max-w-4xl px-6 py-4">
|
||||
<Link
|
||||
href="/writing"
|
||||
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
<BackArrow size={14} />
|
||||
{tCommon("back")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mx-auto max-w-4xl px-6 pb-8">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Badge>{tCat(post.category)}</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, locale)}
|
||||
</span>
|
||||
{readTime && (
|
||||
<span className="flex items-center gap-1 font-mono text-xs text-text-secondary">
|
||||
<Clock size={12} />
|
||||
{readTime} {tCommon("min_read")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="mb-4 text-3xl font-bold leading-tight text-text-primary md:text-4xl lg:text-5xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-lg text-text-secondary leading-relaxed">{post.excerpt}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-4">
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>{post.author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<ShareButtons title={post.title} url={postUrl} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover image */}
|
||||
{coverUrl && (
|
||||
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={post.coverImage?.alt ?? post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="mx-auto max-w-3xl px-6 pb-16">
|
||||
{post.body && <PortableTextRenderer value={post.body as unknown[]} />}
|
||||
|
||||
{/* Tags */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mt-10 flex flex-wrap gap-2 border-t border-border pt-6">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag._id} variant="secondary">{tag.title}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom share */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<ShareButtons title={post.title} url={postUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related posts */}
|
||||
{related && related.length > 0 && (
|
||||
<section className="border-t border-border bg-surface/30 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 className="mb-8 font-mono text-xs text-accent">// {tCommon("related_posts")}</h2>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{related.map((p) => (
|
||||
<PostCard key={p._id} post={p} categoryLabel={tCat(p.category)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/app/[locale]/writing/page.tsx
Normal file
38
src/app/[locale]/writing/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { WritingListClient } from "@/components/writing/WritingListClient";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { allPostsQuery } from "@/lib/sanity/queries";
|
||||
import type { Post } from "@/lib/sanity/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "writing" });
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function WritingPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "writing" });
|
||||
|
||||
const posts = await sanityFetch<Post[]>(allPostsQuery, { locale });
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="writing" />
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</div>
|
||||
<WritingListClient posts={posts ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/app/api/og/route.tsx
Normal file
90
src/app/api/og/route.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const title = searchParams.get("title") ?? "Ali Taghavi";
|
||||
const category = searchParams.get("category") ?? "";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-end",
|
||||
padding: "60px",
|
||||
background: "#0A0A0B",
|
||||
fontFamily: "sans-serif",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Dot grid */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
{/* Accent glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-100px",
|
||||
right: "-100px",
|
||||
width: "400px",
|
||||
height: "400px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(204,253,101,0.07)",
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Category label */}
|
||||
{category && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#CCFD65", fontSize: "14px", fontFamily: "monospace" }}>
|
||||
// {category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 60 ? "36px" : "48px",
|
||||
fontWeight: "700",
|
||||
color: "#FAFAFA",
|
||||
lineHeight: 1.3,
|
||||
maxWidth: "900px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ color: "#CCFD65", fontSize: "18px", fontFamily: "monospace", fontWeight: "700" }}>
|
||||
// ali taghavi
|
||||
</span>
|
||||
<span style={{ color: "#A1A1AA", fontSize: "14px" }}>biztaghavi.com</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ width: 1200, height: 630 }
|
||||
);
|
||||
}
|
||||
13
src/app/api/revalidate/route.ts
Normal file
13
src/app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const secret = req.nextUrl.searchParams.get("secret");
|
||||
|
||||
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||
return Response.json({ message: "Invalid secret" }, { status: 401 });
|
||||
}
|
||||
|
||||
revalidateTag("sanity", "default");
|
||||
return Response.json({ revalidated: true, now: Date.now() });
|
||||
}
|
||||
45
src/app/api/rss/route.ts
Normal file
45
src/app/api/rss/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { rssPostsQuery } from "@/lib/sanity/queries";
|
||||
|
||||
const SITE_URL = "https://biztaghavi.com";
|
||||
|
||||
export async function GET() {
|
||||
const posts = await sanityFetch<
|
||||
Array<{ _id: string; title: string; slug: { current: string }; excerpt?: string; publishedAt: string; category: string }>
|
||||
>(rssPostsQuery);
|
||||
|
||||
const items = (posts ?? [])
|
||||
.map((post) => {
|
||||
const url = `${SITE_URL}/writing/${post.slug.current}`;
|
||||
const date = post.publishedAt ? new Date(post.publishedAt).toUTCString() : "";
|
||||
return `
|
||||
<item>
|
||||
<title><![CDATA[${post.title}]]></title>
|
||||
<link>${url}</link>
|
||||
<guid>${url}</guid>
|
||||
<pubDate>${date}</pubDate>
|
||||
<category>${post.category}</category>
|
||||
${post.excerpt ? `<description><![CDATA[${post.excerpt}]]></description>` : ""}
|
||||
</item>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>علی تقوی — نوشتهها</title>
|
||||
<link>${SITE_URL}</link>
|
||||
<description>یادداشتها، تحلیلها و آموختههایم از ساختن کسبوکار، محصول و برند</description>
|
||||
<language>fa</language>
|
||||
<atom:link href="${SITE_URL}/api/rss" rel="self" type="application/rss+xml" />
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600, s-maxage=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
89
src/app/globals.css
Normal file
89
src/app/globals.css
Normal file
@@ -0,0 +1,89 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/vazirmatn";
|
||||
|
||||
@theme inline {
|
||||
/* Brand colors */
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-border: var(--border-color);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
--color-accent-glow: var(--accent-glow);
|
||||
--color-danger: var(--danger);
|
||||
--color-success: var(--success);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-jakarta);
|
||||
--font-mono: var(--font-jetbrains);
|
||||
--font-persian: "Vazirmatn Variable", "Vazirmatn", system-ui, sans-serif;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #0A0A0B;
|
||||
--surface: #141416;
|
||||
--surface-hover: #1C1C1F;
|
||||
--border-color: #27272A;
|
||||
--text-primary: #FAFAFA;
|
||||
--text-secondary: #A1A1AA;
|
||||
--accent: #CCFD65;
|
||||
--accent-hover: #B8E85A;
|
||||
--accent-muted: rgba(204, 253, 101, 0.1);
|
||||
--accent-glow: rgba(204, 253, 101, 0.25);
|
||||
--danger: #EF4444;
|
||||
--success: #22C55E;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
[lang="fa"] body,
|
||||
[dir="rtl"] {
|
||||
font-family: "Vazirmatn Variable", "Vazirmatn", system-ui, sans-serif;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Dot-grid background texture */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--background); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||
|
||||
/* Selection */
|
||||
::selection { background: var(--accent-muted); color: var(--accent); }
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Ali Taghavi | علی تقوی",
|
||||
template: "%s | Ali Taghavi",
|
||||
},
|
||||
description: "CEO @ NODE-Group · Builder · Strategist · Tehran, Iran",
|
||||
metadataBase: new URL("https://biztaghavi.com"),
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return children;
|
||||
}
|
||||
10
src/app/studio/[[...tool]]/page.tsx
Normal file
10
src/app/studio/[[...tool]]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextStudio } from "next-sanity/studio";
|
||||
import config from "../../../../sanity/sanity.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export { metadata, viewport } from "next-sanity/studio";
|
||||
|
||||
export default function StudioPage() {
|
||||
return <NextStudio config={config} />;
|
||||
}
|
||||
79
src/components/home/BuildingNow.tsx
Normal file
79
src/components/home/BuildingNow.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const products = [
|
||||
{
|
||||
name: "HyperAccount",
|
||||
description: "Identity and account infrastructure for the next web.",
|
||||
tag: "Product",
|
||||
href: "https://hyperaccount.ir",
|
||||
status: "Live",
|
||||
},
|
||||
{
|
||||
name: "Khanehban",
|
||||
description: "Smart property management for Persian landlords and tenants.",
|
||||
tag: "Product",
|
||||
href: "https://khanehban.ir",
|
||||
status: "Beta",
|
||||
},
|
||||
{
|
||||
name: "NODE-AUTH (Dezhban)",
|
||||
description: "Authentication & authorization layer built for Persian products.",
|
||||
tag: "Infrastructure",
|
||||
href: "https://nodegroup.ir",
|
||||
status: "Building",
|
||||
},
|
||||
];
|
||||
|
||||
export function BuildingNow() {
|
||||
const t = useTranslations("sections");
|
||||
|
||||
return (
|
||||
<section className="relative z-10 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<SectionHeader label={t("building_now")} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<a
|
||||
key={product.name}
|
||||
href={product.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block"
|
||||
>
|
||||
<Card className="h-full p-5">
|
||||
<CardContent className="p-0">
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{product.tag}</Badge>
|
||||
<span className={`font-mono text-xs ${
|
||||
product.status === "Live" ? "text-success" :
|
||||
product.status === "Beta" ? "text-accent" : "text-text-secondary"
|
||||
}`}>
|
||||
{product.status}
|
||||
</span>
|
||||
</div>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-text-secondary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 font-mono text-base font-semibold text-text-primary group-hover:text-accent transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">
|
||||
{product.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
98
src/components/home/Hero.tsx
Normal file
98
src/components/home/Hero.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { ArrowRight, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function Hero() {
|
||||
const t = useTranslations("hero");
|
||||
const locale = useLocale();
|
||||
const isRtl = locale === "fa";
|
||||
const taglines = t.raw("taglines") as string[];
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((i) => (i + 1) % taglines.length);
|
||||
}, 3500);
|
||||
return () => clearInterval(interval);
|
||||
}, [taglines.length]);
|
||||
|
||||
const Arrow = isRtl ? ArrowLeft : ArrowRight;
|
||||
|
||||
return (
|
||||
<section className="relative z-10 flex min-h-[90vh] flex-col justify-center px-6 pt-24 pb-16">
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{/* Eyebrow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-6 flex items-center gap-2"
|
||||
>
|
||||
<span className="font-mono text-xs text-accent">// CEO @ NODE-Group</span>
|
||||
<span className="h-px flex-1 max-w-16 bg-accent/30" />
|
||||
</motion.div>
|
||||
|
||||
{/* Name */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="mb-6 text-5xl font-bold tracking-tight text-text-primary md:text-7xl"
|
||||
>
|
||||
{isRtl ? "علی تقوی" : "Ali Taghavi"}
|
||||
</motion.h1>
|
||||
|
||||
{/* Rotating tagline */}
|
||||
<div className="mb-8 h-14 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="text-xl text-text-secondary md:text-2xl"
|
||||
>
|
||||
{taglines[index]}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mb-10 max-w-xl text-base text-text-secondary leading-relaxed"
|
||||
>
|
||||
{t("bio")}
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/work">
|
||||
{t("cta_work")} <Arrow size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg">
|
||||
<Link href="/writing">{t("cta_writing")}</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Accent glow */}
|
||||
<div className="pointer-events-none absolute top-32 start-1/2 -z-10 h-64 w-64 -translate-x-1/2 rounded-full bg-accent/5 blur-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
83
src/components/home/LatestWriting.tsx
Normal file
83
src/components/home/LatestWriting.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowRight, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Placeholder posts — will come from Sanity in Phase 2
|
||||
const placeholderPosts = [
|
||||
{
|
||||
slug: "building-from-tehran",
|
||||
title: "ساختن از تهران: چالشها و فرصتها",
|
||||
titleEn: "Building from Tehran: Challenges and Opportunities",
|
||||
category: "Founder Notes",
|
||||
date: "1403/02/01",
|
||||
readTime: "5 دقیقه",
|
||||
},
|
||||
{
|
||||
slug: "product-thinking-persian-market",
|
||||
title: "تفکر محصول برای بازار ایرانی",
|
||||
titleEn: "Product Thinking for the Persian Market",
|
||||
category: "Product Thinking",
|
||||
date: "1403/01/25",
|
||||
readTime: "8 دقیقه",
|
||||
},
|
||||
{
|
||||
slug: "node-group-story",
|
||||
title: "داستان NODE-Group: از ایده تا محصول",
|
||||
titleEn: "The NODE-Group Story: From Idea to Product",
|
||||
category: "Business Experiments",
|
||||
date: "1403/01/15",
|
||||
readTime: "12 دقیقه",
|
||||
},
|
||||
];
|
||||
|
||||
export function LatestWriting() {
|
||||
const t = useTranslations("sections");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
return (
|
||||
<section className="relative z-10 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<SectionHeader label={t("latest_writing")} className="mb-0" />
|
||||
<Link
|
||||
href="/writing"
|
||||
className="hidden items-center gap-1 text-sm text-text-secondary hover:text-accent transition-colors md:flex"
|
||||
>
|
||||
{tCommon("read_more")} <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{placeholderPosts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/writing/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 py-5 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{post.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">{post.date}</span>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-primary group-hover:text-accent transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 shrink-0">
|
||||
<span className="font-mono text-xs text-text-secondary">{post.readTime}</span>
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className="text-text-secondary opacity-0 transition-opacity group-hover:opacity-100 rtl:rotate-180"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
43
src/components/home/SignalCTA.tsx
Normal file
43
src/components/home/SignalCTA.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignalCTA() {
|
||||
const t = useTranslations("sections");
|
||||
|
||||
return (
|
||||
<section className="relative z-10 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="relative overflow-hidden rounded-xl border border-accent/20 bg-surface p-8 md:p-12">
|
||||
{/* Background glow */}
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-accent/5 blur-3xl" />
|
||||
|
||||
<div className="relative flex flex-col items-start justify-between gap-6 md:flex-row md:items-center">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-accent-muted">
|
||||
<Send size={20} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 text-xl font-bold text-text-primary">
|
||||
{t("signal")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary">{t("signal_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button asChild size="lg" className="shrink-0">
|
||||
<a
|
||||
href="https://t.me/alitaghavi_channel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Send size={16} />
|
||||
{t("signal_cta")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
74
src/components/layout/Footer.tsx
Normal file
74
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Github, Linkedin, Send } from "lucide-react";
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
|
||||
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
|
||||
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
|
||||
];
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/writing", labelKey: "writing" },
|
||||
{ href: "/work", labelKey: "work" },
|
||||
{ href: "/about", labelKey: "about" },
|
||||
{ href: "/contact", labelKey: "contact" },
|
||||
] as const;
|
||||
|
||||
export function Footer() {
|
||||
const t = useTranslations("nav");
|
||||
const tf = useTranslations("footer");
|
||||
|
||||
return (
|
||||
<footer className="relative z-10 border-t border-border bg-surface/50">
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
|
||||
{/* Brand */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link href="/" className="font-mono text-sm font-bold text-text-primary hover:text-accent transition-colors">
|
||||
<span className="text-accent">//</span> ali taghavi
|
||||
</Link>
|
||||
<p className="max-w-xs text-sm text-text-secondary leading-relaxed">
|
||||
CEO @ NODE-Group · Builder · Strategist
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{navLinks.map(({ href, labelKey }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Social */}
|
||||
<div className="flex items-center gap-3">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-border pt-6 text-xs text-text-secondary md:flex-row">
|
||||
<span>© {new Date().getFullYear()} Ali Taghavi — {tf("rights")}</span>
|
||||
<span className="font-mono">NODE-Group · Tehran, Iran</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
69
src/components/layout/Header.tsx
Normal file
69
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
|
||||
const navKeys = ["writing", "work", "about", "contact"] as const;
|
||||
const navHrefs: Record<string, string> = {
|
||||
writing: "/writing",
|
||||
work: "/work",
|
||||
about: "/about",
|
||||
contact: "/contact",
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const t = useTranslations("nav");
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener("scroll", handler, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 z-40 w-full transition-all duration-300",
|
||||
scrolled
|
||||
? "border-b border-border bg-background/80 backdrop-blur-md"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="font-mono text-sm font-bold tracking-tight text-text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
<span className="text-accent">//</span> ali taghavi
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{navKeys.map((key) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={navHrefs[key]}
|
||||
className="rounded-md px-3 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary hover:bg-surface-hover"
|
||||
>
|
||||
{t(key)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden md:block">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<MobileMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
35
src/components/layout/LanguageSwitcher.tsx
Normal file
35
src/components/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/lib/i18n/navigation";
|
||||
import { locales, localeNames, type Locale } from "@/lib/i18n/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale() as Locale;
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
function switchLocale(next: Locale) {
|
||||
router.replace(pathname, { locale: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-1">
|
||||
{locales.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => switchLocale(l)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-all duration-200",
|
||||
locale === l
|
||||
? "bg-accent text-background"
|
||||
: "text-text-secondary hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
{l === "fa" ? "فا" : "EN"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/layout/MobileMenu.tsx
Normal file
66
src/components/layout/MobileMenu.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
const navKeys = ["home", "writing", "work", "about", "contact"] as const;
|
||||
const navHrefs: Record<string, string> = {
|
||||
home: "/",
|
||||
writing: "/writing",
|
||||
work: "/work",
|
||||
about: "/about",
|
||||
contact: "/contact",
|
||||
};
|
||||
|
||||
export function MobileMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useTranslations("nav");
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="p-2 text-text-secondary hover:text-text-primary md:hidden"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-md md:hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<span className="font-mono text-sm text-accent">// menu</span>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-2 px-6 pt-8">
|
||||
{navKeys.map((key) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={navHrefs[key]}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-4 py-3 text-lg font-medium text-text-secondary transition-colors hover:bg-surface hover:text-text-primary"
|
||||
>
|
||||
{t(key)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-6 py-6">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/components/shared/AnimatedSection.tsx
Normal file
28
src/components/shared/AnimatedSection.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function AnimatedSection({ children, className, delay = 0 }: AnimatedSectionProps) {
|
||||
const ref = useRef(null);
|
||||
const inView = useInView(ref, { once: true, margin: "-80px" });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay, ease: "easeOut" }}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
21
src/components/shared/SectionHeader.tsx
Normal file
21
src/components/shared/SectionHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
label: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({ label, title, className }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("mb-8", className)}>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-accent">// {label}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{title && (
|
||||
<h2 className="text-2xl font-bold text-text-primary md:text-3xl">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent-muted text-accent border border-accent/20",
|
||||
secondary: "bg-surface text-text-secondary border border-border",
|
||||
outline: "border border-border text-text-secondary",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-accent text-background hover:bg-accent-hover font-semibold shadow-[0_0_20px_var(--accent-glow)]",
|
||||
secondary:
|
||||
"border border-border bg-surface text-text-primary hover:bg-surface-hover hover:border-accent/40",
|
||||
ghost:
|
||||
"text-text-secondary hover:text-text-primary hover:bg-surface-hover",
|
||||
link: "text-accent underline-offset-4 hover:underline p-0 h-auto",
|
||||
outline:
|
||||
"border border-accent/40 text-accent hover:bg-accent-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-12 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
63
src/components/ui/card.tsx
Normal file
63
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-surface backdrop-blur-sm transition-all duration-200 hover:border-accent/20 hover:bg-surface-hover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-tight text-text-primary", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-text-secondary", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
89
src/components/work/ProjectCard.tsx
Normal file
89
src/components/work/ProjectCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Image from "next/image";
|
||||
import { ExternalLink, Github } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import type { Project } from "@/lib/sanity/types";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
typeLabel?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, typeLabel }: ProjectCardProps) {
|
||||
const imageUrl = project.coverImage?.asset
|
||||
? urlFor(project.coverImage).width(800).height(450).url()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Link href={`/work/${project.slug.current}`} className="group block h-full">
|
||||
<Card className="h-full overflow-hidden">
|
||||
{imageUrl ? (
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={project.coverImage?.alt ?? project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 bg-surface-hover flex items-center justify-center">
|
||||
<span className="font-mono text-2xl text-accent opacity-30">//</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<Badge variant="secondary">{typeLabel ?? project.projectType}</Badge>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-text-secondary hover:text-accent transition-colors"
|
||||
aria-label="Live demo"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-text-secondary hover:text-accent transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 font-semibold text-text-primary group-hover:text-accent transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-text-secondary line-clamp-3">{project.description}</p>
|
||||
)}
|
||||
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{project.techStack.slice(0, 4).map((tech) => (
|
||||
<span key={tech} className="rounded-full bg-surface-hover px-2 py-0.5 font-mono text-xs text-text-secondary">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
49
src/components/work/WorkListClient.tsx
Normal file
49
src/components/work/WorkListClient.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import type { Project, ProjectType } from "@/lib/sanity/types";
|
||||
|
||||
const types: Array<ProjectType | "all"> = ["all", "product", "brand-system", "open-source", "creative-work"];
|
||||
|
||||
export function WorkListClient({ projects }: { projects: Project[] }) {
|
||||
const [active, setActive] = useState<ProjectType | "all">("all");
|
||||
const tType = useTranslations("work.types");
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (active === "all" ? projects : projects.filter((p) => p.projectType === active)),
|
||||
[projects, active]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Type filter */}
|
||||
<div className="mb-8 flex flex-wrap gap-2">
|
||||
{types.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActive(type)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
||||
active === type
|
||||
? "bg-accent text-background"
|
||||
: "border border-border text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
{tType(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mb-6 font-mono text-xs text-text-secondary">{filtered.length} پروژه</p>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((project) => (
|
||||
<ProjectCard key={project._id} project={project} typeLabel={tType(project.projectType)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/writing/CategoryFilter.tsx
Normal file
43
src/components/writing/CategoryFilter.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PostCategory } from "@/lib/sanity/types";
|
||||
|
||||
const categories: Array<PostCategory | "all"> = [
|
||||
"all",
|
||||
"founder-notes",
|
||||
"marketing-branding",
|
||||
"product-thinking",
|
||||
"tech-builds",
|
||||
"business-experiments",
|
||||
"systems-productivity",
|
||||
];
|
||||
|
||||
interface CategoryFilterProps {
|
||||
active: PostCategory | "all";
|
||||
onChange: (cat: PostCategory | "all") => void;
|
||||
}
|
||||
|
||||
export function CategoryFilter({ active, onChange }: CategoryFilterProps) {
|
||||
const t = useTranslations("writing.categories");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => onChange(cat)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
||||
active === cat
|
||||
? "bg-accent text-background"
|
||||
: "border border-border text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
{t(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/writing/PortableTextRenderer.tsx
Normal file
106
src/components/writing/PortableTextRenderer.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { PortableText, type PortableTextComponents, type PortableTextBlock } from "@portabletext/react";
|
||||
import Image from "next/image";
|
||||
import { highlight } from "sugar-high";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
|
||||
function CodeBlock({ value }: { value: { code: string; language?: string } }) {
|
||||
const html = highlight(value.code ?? "");
|
||||
return (
|
||||
<div className="my-6 overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{value.language && (
|
||||
<div className="flex items-center border-b border-border px-4 py-2">
|
||||
<span className="font-mono text-xs text-accent">{value.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto p-4">
|
||||
<code
|
||||
className="font-mono text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostImage({ value }: { value: { asset: unknown; alt?: string } }) {
|
||||
if (!value?.asset) return null;
|
||||
const url = urlFor(value).width(1200).url();
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border">
|
||||
<Image src={url} alt={value.alt ?? ""} fill className="object-cover" />
|
||||
</div>
|
||||
{value.alt && (
|
||||
<figcaption className="mt-2 text-center text-sm text-text-secondary">{value.alt}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const components = {
|
||||
types: {
|
||||
code: CodeBlock,
|
||||
image: PostImage,
|
||||
},
|
||||
block: {
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="mb-4 mt-10 text-3xl font-bold text-text-primary">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className="mb-3 mt-8 text-2xl font-bold text-text-primary">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-text-primary">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className="mb-2 mt-5 text-lg font-semibold text-text-primary">{children}</h4>
|
||||
),
|
||||
normal: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="mb-5 leading-relaxed text-text-primary/90">{children}</p>
|
||||
),
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="my-6 border-s-2 border-accent ps-5 text-text-secondary italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
},
|
||||
list: {
|
||||
bullet: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="mb-5 list-disc ps-6 space-y-2 text-text-primary/90">{children}</ul>
|
||||
),
|
||||
number: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="mb-5 list-decimal ps-6 space-y-2 text-text-primary/90">{children}</ol>
|
||||
),
|
||||
},
|
||||
marks: {
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="font-semibold text-text-primary">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="rounded bg-surface px-1.5 py-0.5 font-mono text-sm text-accent border border-border">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
link: ({ value, children }: { value?: { href: string }; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={value?.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-hover"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function PortableTextRenderer({ value }: { value: unknown[] }) {
|
||||
return (
|
||||
<div className="prose-custom max-w-none">
|
||||
<PortableText value={value as PortableTextBlock[]} components={components as PortableTextComponents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/writing/PostCard.tsx
Normal file
89
src/components/writing/PostCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Image from "next/image";
|
||||
import { useLocale } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import { formatDate, estimateReadTime } from "@/lib/sanity/utils";
|
||||
import type { Post } from "@/lib/sanity/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
view?: "grid" | "list";
|
||||
categoryLabel?: string;
|
||||
}
|
||||
|
||||
export function PostCard({ post, view = "grid", categoryLabel }: PostCardProps) {
|
||||
const locale = useLocale();
|
||||
const readTime = post.body ? estimateReadTime(post.body as unknown[]) : null;
|
||||
const imageUrl = post.coverImage?.asset
|
||||
? urlFor(post.coverImage).width(800).height(450).url()
|
||||
: null;
|
||||
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Link href={`/writing/${post.slug.current}`} className="group block">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border py-5 hover:border-accent/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{categoryLabel ?? post.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-text-primary group-hover:text-accent transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.excerpt && (
|
||||
<p className="mt-1 text-sm text-text-secondary line-clamp-2">{post.excerpt}</p>
|
||||
)}
|
||||
</div>
|
||||
{readTime && (
|
||||
<span className="shrink-0 font-mono text-xs text-text-secondary mt-1">
|
||||
{readTime} دقیقه
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/writing/${post.slug.current}`} className="group block h-full">
|
||||
<Card className="h-full overflow-hidden">
|
||||
{imageUrl && (
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.coverImage?.alt ?? post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className={cn("p-5", !imageUrl && "pt-5")}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant="default">{categoryLabel ?? post.category}</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-semibold text-text-primary group-hover:text-accent transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.excerpt && (
|
||||
<p className="text-sm text-text-secondary line-clamp-3">{post.excerpt}</p>
|
||||
)}
|
||||
{readTime && (
|
||||
<div className="mt-4 font-mono text-xs text-text-secondary">
|
||||
{readTime} دقیقه مطالعه
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
27
src/components/writing/ReadingProgress.tsx
Normal file
27
src/components/writing/ReadingProgress.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
const doc = document.documentElement;
|
||||
const scrolled = doc.scrollTop;
|
||||
const total = doc.scrollHeight - doc.clientHeight;
|
||||
setProgress(total > 0 ? (scrolled / total) * 100 : 0);
|
||||
}
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
return () => window.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 start-0 z-50 h-0.5 w-full bg-transparent">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/writing/ShareButtons.tsx
Normal file
55
src/components/writing/ShareButtons.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link2, Twitter, Send, Check } from "lucide-react";
|
||||
|
||||
interface ShareButtonsProps {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function ShareButtons({ title, url }: ShareButtonsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const t = useTranslations("common");
|
||||
|
||||
async function copyLink() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary">{t("share")}:</span>
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<Twitter size={14} />
|
||||
</a>
|
||||
<a
|
||||
href={`https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
|
||||
aria-label="Share on Telegram"
|
||||
>
|
||||
<Send size={14} />
|
||||
</a>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
|
||||
aria-label={t("copy_link")}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-success" /> : <Link2 size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/writing/WritingListClient.tsx
Normal file
105
src/components/writing/WritingListClient.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutGrid, List, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PostCard } from "./PostCard";
|
||||
import { CategoryFilter } from "./CategoryFilter";
|
||||
import type { Post, PostCategory } from "@/lib/sanity/types";
|
||||
|
||||
interface WritingListClientProps {
|
||||
posts: Post[];
|
||||
}
|
||||
|
||||
export function WritingListClient({ posts }: WritingListClientProps) {
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [category, setCategory] = useState<PostCategory | "all">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useTranslations("writing");
|
||||
const tCommon = useTranslations("common");
|
||||
const tCat = useTranslations("writing.categories");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return posts.filter((p) => {
|
||||
const matchesCat = category === "all" || p.category === category;
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.excerpt ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
return matchesCat && matchesSearch;
|
||||
});
|
||||
}, [posts, category, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Controls */}
|
||||
<div className="mb-8 flex flex-col gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute start-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={tCommon("search_placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface ps-10 pe-4 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filter + view toggle */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<CategoryFilter active={category} onChange={setCategory} />
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-border bg-surface p-1">
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={cn("rounded p-1.5 transition-colors", view === "grid" ? "bg-accent text-background" : "text-text-secondary hover:text-text-primary")}
|
||||
aria-label={t("grid_view")}
|
||||
>
|
||||
<LayoutGrid size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={cn("rounded p-1.5 transition-colors", view === "list" ? "bg-accent text-background" : "text-text-secondary hover:text-text-primary")}
|
||||
aria-label={t("list_view")}
|
||||
>
|
||||
<List size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="mb-6 font-mono text-xs text-text-secondary">
|
||||
{filtered.length} نوشته
|
||||
</p>
|
||||
|
||||
{/* Posts */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-text-secondary">{tCommon("no_results")}</div>
|
||||
) : view === "grid" ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((post) => (
|
||||
<PostCard
|
||||
key={post._id}
|
||||
post={post}
|
||||
view="grid"
|
||||
categoryLabel={tCat(post.category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filtered.map((post) => (
|
||||
<PostCard
|
||||
key={post._id}
|
||||
post={post}
|
||||
view="list"
|
||||
categoryLabel={tCat(post.category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/lib/fonts.ts
Normal file
13
src/lib/fonts.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google";
|
||||
|
||||
export const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-jetbrains",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
variable: "--font-jakarta",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
13
src/lib/i18n/config.ts
Normal file
13
src/lib/i18n/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const locales = ["fa", "en"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = "fa";
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
fa: "فارسی",
|
||||
en: "English",
|
||||
};
|
||||
|
||||
export const localeDir: Record<Locale, "rtl" | "ltr"> = {
|
||||
fa: "rtl",
|
||||
en: "ltr",
|
||||
};
|
||||
5
src/lib/i18n/navigation.ts
Normal file
5
src/lib/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
15
src/lib/i18n/request.ts
Normal file
15
src/lib/i18n/request.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
if (!locale || !routing.locales.includes(locale as "fa" | "en")) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
8
src/lib/i18n/routing.ts
Normal file
8
src/lib/i18n/routing.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
import { locales, defaultLocale } from "./config";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
18
src/lib/sanity/client.ts
Normal file
18
src/lib/sanity/client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createClient } from "next-sanity";
|
||||
|
||||
export const client = createClient({
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production",
|
||||
apiVersion: "2024-01-01",
|
||||
useCdn: process.env.NODE_ENV === "production",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
export async function sanityFetch<T>(
|
||||
query: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return client.fetch<T>(query, params, {
|
||||
next: { tags: ["sanity"], revalidate: process.env.NODE_ENV === "development" ? 0 : 3600 },
|
||||
});
|
||||
}
|
||||
9
src/lib/sanity/image.ts
Normal file
9
src/lib/sanity/image.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import imageUrlBuilder from "@sanity/image-url";
|
||||
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
|
||||
import { client } from "./client";
|
||||
|
||||
const builder = imageUrlBuilder(client);
|
||||
|
||||
export function urlFor(source: SanityImageSource) {
|
||||
return builder.image(source);
|
||||
}
|
||||
98
src/lib/sanity/queries.ts
Normal file
98
src/lib/sanity/queries.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { groq } from "next-sanity";
|
||||
|
||||
// ── Fragments ──────────────────────────────────────────────────────────────
|
||||
|
||||
const postFields = groq`
|
||||
_id, title, slug, locale, category, excerpt, publishedAt, featured,
|
||||
"coverImage": coverImage { asset->, alt },
|
||||
"tags": tags[]->{ _id, title, slug },
|
||||
"author": author->{ name, image }
|
||||
`;
|
||||
|
||||
const projectFields = groq`
|
||||
_id, title, slug, locale, projectType, description, featured,
|
||||
techStack, toolsUsed, liveUrl, githubUrl,
|
||||
"coverImage": coverImage { asset->, alt }
|
||||
`;
|
||||
|
||||
// ── Posts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const allPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale] | order(publishedAt desc) {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const postsByCategoryQuery = groq`
|
||||
*[_type == "post" && locale == $locale && category == $category] | order(publishedAt desc) {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const featuredPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale && featured == true] | order(publishedAt desc)[0...4] {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const postBySlugQuery = groq`
|
||||
*[_type == "post" && slug.current == $slug][0] {
|
||||
${postFields},
|
||||
body,
|
||||
"seo": seo { title, description, "ogImage": ogImage.asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
export const relatedPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale && category == $category && slug.current != $slug] | order(publishedAt desc)[0...3] {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const allPostSlugsQuery = groq`
|
||||
*[_type == "post"] { "slug": slug.current, locale }
|
||||
`;
|
||||
|
||||
// ── Projects ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const allProjectsQuery = groq`
|
||||
*[_type == "project" && locale == $locale] | order(_createdAt desc) {
|
||||
${projectFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const projectsByTypeQuery = groq`
|
||||
*[_type == "project" && locale == $locale && projectType == $projectType] | order(_createdAt desc) {
|
||||
${projectFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const projectBySlugQuery = groq`
|
||||
*[_type == "project" && slug.current == $slug][0] {
|
||||
${projectFields},
|
||||
body,
|
||||
"gallery": gallery[] { asset->, alt },
|
||||
"seo": seo { title, description, "ogImage": ogImage.asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
export const allProjectSlugsQuery = groq`
|
||||
*[_type == "project"] { "slug": slug.current, locale }
|
||||
`;
|
||||
|
||||
// ── Site Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
export const siteSettingsQuery = groq`
|
||||
*[_type == "siteSettings"][0] {
|
||||
siteTitle, description, currentStatus, telegramChannel, socialLinks,
|
||||
"defaultOgImage": defaultOgImage.asset->
|
||||
}
|
||||
`;
|
||||
|
||||
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
|
||||
|
||||
export const rssPostsQuery = groq`
|
||||
*[_type == "post" && locale == "fa"] | order(publishedAt desc)[0...20] {
|
||||
_id, title, slug, excerpt, publishedAt, category
|
||||
}
|
||||
`;
|
||||
63
src/lib/sanity/types.ts
Normal file
63
src/lib/sanity/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type PostCategory =
|
||||
| "founder-notes"
|
||||
| "marketing-branding"
|
||||
| "product-thinking"
|
||||
| "tech-builds"
|
||||
| "business-experiments"
|
||||
| "systems-productivity";
|
||||
|
||||
export type ProjectType = "product" | "brand-system" | "open-source" | "creative-work";
|
||||
|
||||
export interface SanityImage {
|
||||
asset: { url: string; metadata: { lqip: string; dimensions: { width: number; height: number } } };
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
locale: "fa" | "en";
|
||||
category: PostCategory;
|
||||
excerpt?: string;
|
||||
publishedAt: string;
|
||||
featured?: boolean;
|
||||
coverImage?: SanityImage;
|
||||
tags?: { _id: string; title: string; slug: { current: string } }[];
|
||||
author?: { name: string; image?: SanityImage };
|
||||
body?: unknown[];
|
||||
seo?: { title?: string; description?: string; ogImage?: { url: string } };
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
locale: "fa" | "en";
|
||||
projectType: ProjectType;
|
||||
description?: string;
|
||||
featured?: boolean;
|
||||
coverImage?: SanityImage;
|
||||
gallery?: SanityImage[];
|
||||
techStack?: string[];
|
||||
toolsUsed?: string[];
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
body?: unknown[];
|
||||
seo?: { title?: string; description?: string; ogImage?: { url: string } };
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
siteTitle?: string;
|
||||
description?: string;
|
||||
currentStatus?: string;
|
||||
telegramChannel?: string;
|
||||
socialLinks?: {
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
};
|
||||
defaultOgImage?: { url: string };
|
||||
}
|
||||
34
src/lib/sanity/utils.ts
Normal file
34
src/lib/sanity/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export function estimateReadTime(body: unknown[]): number {
|
||||
if (!body) return 1;
|
||||
const text = body
|
||||
.filter((b: unknown) => (b as { _type: string })._type === "block")
|
||||
.map((b: unknown) => {
|
||||
const block = b as { children?: { text?: string }[] };
|
||||
return block.children?.map((c) => c.text ?? "").join("") ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
|
||||
export function formatPersianDate(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(d);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, locale: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
}).format(d);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
83
src/messages/en.json
Normal file
83
src/messages/en.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"writing": "Writing",
|
||||
"work": "Work",
|
||||
"about": "About",
|
||||
"resume": "Resume",
|
||||
"shop": "Shop",
|
||||
"contact": "Contact",
|
||||
"uses": "Uses"
|
||||
},
|
||||
"hero": {
|
||||
"taglines": [
|
||||
"Building products, brands, and systems from Tehran",
|
||||
"CEO @ NODE-Group · Creator · Strategist",
|
||||
"I build things that work — in code, in markets, in culture"
|
||||
],
|
||||
"bio": "I'm Ali Taghavi. A builder from Tehran operating at the intersection of technology, branding, marketing, and product strategy.",
|
||||
"cta_work": "See my work",
|
||||
"cta_writing": "Read my writing"
|
||||
},
|
||||
"sections": {
|
||||
"selected_work": "Selected Work",
|
||||
"building_now": "What I'm Building",
|
||||
"latest_writing": "Latest Writing",
|
||||
"signal": "Telegram Channel",
|
||||
"signal_desc": "Raw notes, ideas, and daily updates",
|
||||
"signal_cta": "Join the channel"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "All rights reserved",
|
||||
"built_with": "Built with"
|
||||
},
|
||||
"common": {
|
||||
"read_more": "Read more",
|
||||
"view_project": "View project",
|
||||
"back": "Back",
|
||||
"loading": "Loading...",
|
||||
"error": "Something went wrong",
|
||||
"all": "All",
|
||||
"search_placeholder": "Search...",
|
||||
"no_results": "No results found",
|
||||
"min_read": "min read",
|
||||
"share": "Share",
|
||||
"copy_link": "Copy link",
|
||||
"copied": "Copied!",
|
||||
"table_of_contents": "Table of Contents",
|
||||
"related_posts": "Related Posts",
|
||||
"live_demo": "Live Demo",
|
||||
"source_code": "Source Code"
|
||||
},
|
||||
"writing": {
|
||||
"title": "Writing",
|
||||
"subtitle": "Notes, analysis, and lessons from building businesses, products, and brands",
|
||||
"grid_view": "Grid",
|
||||
"list_view": "List",
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"founder-notes": "Founder Notes",
|
||||
"marketing-branding": "Marketing & Branding",
|
||||
"product-thinking": "Product Thinking",
|
||||
"tech-builds": "Tech Builds",
|
||||
"business-experiments": "Business Experiments",
|
||||
"systems-productivity": "Systems & Productivity"
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"title": "Work",
|
||||
"subtitle": "Products, brands, and projects I've built",
|
||||
"types": {
|
||||
"all": "All",
|
||||
"product": "Product",
|
||||
"brand-system": "Brand System",
|
||||
"open-source": "Open Source",
|
||||
"creative-work": "Creative Work"
|
||||
},
|
||||
"problem": "The Problem",
|
||||
"approach": "Approach",
|
||||
"outcome": "Outcome",
|
||||
"tech_stack": "Tech Stack",
|
||||
"tools": "Tools Used"
|
||||
}
|
||||
}
|
||||
83
src/messages/fa.json
Normal file
83
src/messages/fa.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "خانه",
|
||||
"writing": "نوشتهها",
|
||||
"work": "کارها",
|
||||
"about": "درباره",
|
||||
"resume": "رزومه",
|
||||
"shop": "فروشگاه",
|
||||
"contact": "تماس",
|
||||
"uses": "ابزارها"
|
||||
},
|
||||
"hero": {
|
||||
"taglines": [
|
||||
"ساختن محصول، برند و سیستم از تهران",
|
||||
"مدیرعامل NODE-Group · سازنده · استراتژیست",
|
||||
"چیزهایی میسازم که کار میکنند — در کد، در بازار، در فرهنگ"
|
||||
],
|
||||
"bio": "علی تقوی هستم. سازندهای از تهران که در تقاطع فناوری، برندینگ، بازاریابی و استراتژی محصول کار میکند.",
|
||||
"cta_work": "کارهایم را ببینید",
|
||||
"cta_writing": "نوشتههایم را بخوانید"
|
||||
},
|
||||
"sections": {
|
||||
"selected_work": "نمونه کارها",
|
||||
"building_now": "در حال ساختن",
|
||||
"latest_writing": "آخرین نوشتهها",
|
||||
"signal": "کانال تلگرام",
|
||||
"signal_desc": "یادداشتهای خام، ایدهها و آپدیتهای روزانه",
|
||||
"signal_cta": "عضو کانال شو"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "تمام حقوق محفوظ است",
|
||||
"built_with": "ساخته شده با"
|
||||
},
|
||||
"common": {
|
||||
"read_more": "ادامه بخوانید",
|
||||
"view_project": "مشاهده پروژه",
|
||||
"back": "بازگشت",
|
||||
"loading": "در حال بارگذاری...",
|
||||
"error": "خطایی رخ داد",
|
||||
"all": "همه",
|
||||
"search_placeholder": "جستجو...",
|
||||
"no_results": "نتیجهای یافت نشد",
|
||||
"min_read": "دقیقه مطالعه",
|
||||
"share": "اشتراکگذاری",
|
||||
"copy_link": "کپی لینک",
|
||||
"copied": "کپی شد!",
|
||||
"table_of_contents": "فهرست مطالب",
|
||||
"related_posts": "نوشتههای مرتبط",
|
||||
"live_demo": "نسخه زنده",
|
||||
"source_code": "کد منبع"
|
||||
},
|
||||
"writing": {
|
||||
"title": "نوشتهها",
|
||||
"subtitle": "یادداشتها، تحلیلها و آموختههایم از ساختن کسبوکار، محصول و برند",
|
||||
"grid_view": "نمای شبکه",
|
||||
"list_view": "نمای لیست",
|
||||
"categories": {
|
||||
"all": "همه",
|
||||
"founder-notes": "یادداشتهای بنیانگذار",
|
||||
"marketing-branding": "بازاریابی و برندینگ",
|
||||
"product-thinking": "تفکر محصول",
|
||||
"tech-builds": "ساختهای فنی",
|
||||
"business-experiments": "آزمایشهای کسبوکار",
|
||||
"systems-productivity": "سیستمها و بهرهوری"
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"title": "کارها",
|
||||
"subtitle": "محصولات، برندها و پروژههایی که ساختهام",
|
||||
"types": {
|
||||
"all": "همه",
|
||||
"product": "محصول",
|
||||
"brand-system": "سیستم برند",
|
||||
"open-source": "متنباز",
|
||||
"creative-work": "اثر خلاقانه"
|
||||
},
|
||||
"problem": "مسئله",
|
||||
"approach": "رویکرد",
|
||||
"outcome": "نتیجه",
|
||||
"tech_stack": "تکنولوژیها",
|
||||
"tools": "ابزارها"
|
||||
}
|
||||
}
|
||||
8
src/proxy.ts
Normal file
8
src/proxy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./lib/i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|_vercel|studio|api|.*\\..*).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user