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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user