phase 1-2 done

This commit is contained in:
Ali Taghavi
2026-04-24 03:42:34 +03:30
commit 95a546df11
83 changed files with 17168 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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
View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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
View 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 }
);
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

89
src/app/globals.css Normal file
View 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
View 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;
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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 };

View 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 };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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",
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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|.*\\..*).*)"],
};