feat: add admin API routes for tags, timeline events, uploads, and uses management
Some checks failed
Deploy to VPS / deploy (push) Has been cancelled

- Implemented GET and POST endpoints for managing tags in `src/app/api/admin/tags/route.ts`.
- Created PUT and DELETE endpoints for timeline events in `src/app/api/admin/timeline/[id]/route.ts`.
- Added GET and POST endpoints for timeline management in `src/app/api/admin/timeline/route.ts`.
- Developed file upload functionality with validation in `src/app/api/admin/upload/route.ts`.
- Introduced PUT and DELETE endpoints for managing uses items in `src/app/api/admin/uses/[id]/route.ts`.
- Added GET and POST endpoints for uses management in `src/app/api/admin/uses/route.ts`.

feat: enhance admin UI components for better user experience

- Created `AdminSidebar` component for navigation in `src/components/admin/AdminSidebar.tsx`.
- Developed `ImageUpload` component for handling image uploads in `src/components/admin/ImageUpload.tsx`.
- Implemented `RichTextEditor` component for rich text editing in `src/components/admin/RichTextEditor.tsx`.
- Added `TagsInput` component for managing tags in `src/components/admin/TagsInput.tsx`.
- Created `TiptapRenderer` component for rendering HTML content in `src/components/writing/TiptapRenderer.tsx`.

feat: establish database interaction layer with Prisma

- Added database connection and session management in `src/lib/db.ts` and `src/lib/auth.ts`.
- Implemented CRUD operations for posts, products, projects, settings, timeline events, and uses items in respective files under `src/lib/db/`.
- Introduced utility functions for formatting dates and slug generation in `src/lib/types.ts`.
This commit is contained in:
Ali Taghavi
2026-05-03 14:07:58 +03:30
parent f08d3b1fde
commit 29b70c6865
93 changed files with 4561 additions and 1080 deletions

View File

@@ -6,10 +6,8 @@ import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { Timeline } from "@/components/about/Timeline";
import { Values } from "@/components/about/Values";
import { FocusAreas } from "@/components/about/FocusAreas";
import { sanityFetch } from "@/lib/sanity/client";
import { siteSettingsQuery } from "@/lib/sanity/queries";
import type { SiteSettings } from "@/lib/sanity/types";
import { groq } from "next-sanity";
import { getSiteSettings } from "@/lib/db/settings";
import { getAllTimelineEvents } from "@/lib/db/timeline";
interface Props {
params: Promise<{ locale: string }>;
@@ -21,11 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: t("title"), description: t("subtitle") };
}
const timelineQuery = groq`
*[_type == "timelineEvent"] | order(date desc) {
title, date, description, icon, category
}
`;
export const dynamic = "force-dynamic";
const socialLinks = [
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
@@ -38,13 +32,38 @@ export default async function AboutPage({ params }: Props) {
const { locale } = await params;
const [t, settings, timelineEvents] = await Promise.all([
getTranslations({ locale, namespace: "about" }),
sanityFetch<SiteSettings>(siteSettingsQuery),
sanityFetch<unknown[]>(timelineQuery),
getSiteSettings(),
getAllTimelineEvents(),
]);
const values = t.raw("values") as { title: string; desc: string }[];
const focusAreas = t.raw("focus_areas") as string[];
const dbLinks = {
github: settings?.socialGithub,
linkedin: settings?.socialLinkedin,
twitter: settings?.socialTwitter,
telegram: settings?.socialTelegram,
};
const links = socialLinks.map((l) => ({
...l,
href:
(l.label === "GitHub" && dbLinks.github) ||
(l.label === "LinkedIn" && dbLinks.linkedin) ||
(l.label === "Twitter/X" && dbLinks.twitter) ||
(l.label === "Telegram" && dbLinks.telegram) ||
l.href,
}));
const timelineItems = timelineEvents.map((e) => ({
title: e.title,
date: new Intl.DateTimeFormat("fa-IR", { year: "numeric" }).format(new Date(e.date)),
description: e.description ?? undefined,
icon: e.icon ?? undefined,
category: (e.category as "career" | "product" | "personal") ?? "career",
}));
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="about" />
@@ -56,16 +75,14 @@ export default async function AboutPage({ params }: Props) {
<h1 className="mb-6 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
<div className="space-y-4 max-w-2xl">
{t("bio").split("\n\n").map((paragraph, i) => (
<p key={i} className="text-base text-text-secondary leading-relaxed">
{paragraph}
</p>
<p key={i} className="text-base text-text-secondary leading-relaxed">{paragraph}</p>
))}
</div>
<div className="mt-8 flex items-center gap-3">
{socialLinks.map(({ icon: Icon, href, label }) => (
{links.map(({ icon: Icon, href, label }) => (
<a
key={href}
href={href}
key={label}
href={href as string}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
@@ -108,7 +125,7 @@ export default async function AboutPage({ params }: Props) {
{/* Timeline */}
<AnimatedSection>
<SectionHeader label={t("journey")} />
<Timeline events={timelineEvents as Parameters<typeof Timeline>[0]["events"]} />
<Timeline events={timelineItems} />
</AnimatedSection>
</div>
);

View File

@@ -4,7 +4,7 @@ import { BuildingNow } from "@/components/home/BuildingNow";
import { LatestWriting } from "@/components/home/LatestWriting";
import { SignalCTA } from "@/components/home/SignalCTA";
export default function HomePage() {
export default function HomePage({ params }: { params: { locale: string } }) {
return (
<>
<Hero />
@@ -12,7 +12,7 @@ export default function HomePage() {
<BuildingNow />
</AnimatedSection>
<AnimatedSection delay={0.1}>
<LatestWriting />
<LatestWriting locale={params.locale} />
</AnimatedSection>
<AnimatedSection delay={0.2}>
<SignalCTA />

View File

@@ -6,77 +6,32 @@ import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { sanityFetch } from "@/lib/sanity/client";
import { allProductsQuery } from "@/lib/sanity/queries";
import { urlFor } from "@/lib/sanity/image";
import type { SanityImage } from "@/lib/sanity/types";
import { getAllProducts } from "@/lib/db/products";
import type { Product } from "@prisma/client";
interface Props {
params: Promise<{ locale: string }>;
}
interface Product {
_id: string;
title: string;
slug: { current: string };
description?: string;
price?: number;
currency?: string;
productType?: string;
purchaseUrl?: string;
featured?: boolean;
coverImage?: SanityImage;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "shop" });
return { title: t("title"), description: t("subtitle") };
}
// Placeholder products shown when Sanity has no data yet
const placeholders: Product[] = [
{
_id: "p1",
title: "دوره بازاریابی دیجیتال برای استارتاپ‌های ایرانی",
slug: { current: "digital-marketing-course" },
description: "آموزش جامع استراتژی بازاریابی دیجیتال متناسب با بازار ایران — از صفر تا اجرا",
price: 490000,
currency: "IRR",
productType: "digital",
},
{
_id: "p2",
title: "قالب استراتژی برند",
slug: { current: "brand-strategy-template" },
description: "قالب آماده برای طراحی هویت برند و استراتژی بصری کسب‌وکار",
price: 0,
currency: "IRR",
productType: "digital",
},
{
_id: "p3",
title: "HyperAccount",
slug: { current: "hyperaccount" },
description: "زیرساخت هویت و حساب کاربری برای محصولات دیجیتال ایرانی",
productType: "node-product",
purchaseUrl: "https://hyperaccount.ir",
},
];
export const dynamic = "force-dynamic";
export default async function ShopPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "shop" });
const isRtl = locale === "fa";
const sanityProducts = await sanityFetch<Product[]>(allProductsQuery, { locale });
const products = sanityProducts && sanityProducts.length > 0 ? sanityProducts : placeholders;
const products = await getAllProducts(locale);
function formatPrice(price: number, currency: string) {
function formatPrice(price: number | null, currency: string) {
if (price === null || price === undefined) return null;
if (price === 0) return isRtl ? t("free") : "Free";
if (currency === "IRR") {
return new Intl.NumberFormat("fa-IR").format(price) + " تومان";
}
if (currency === "IRR") return new Intl.NumberFormat("fa-IR").format(price) + " تومان";
return `$${price}`;
}
@@ -88,67 +43,67 @@ export default async function ShopPage({ params }: Props) {
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
</AnimatedSection>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product, i) => {
const imageUrl = product.coverImage?.asset
? urlFor(product.coverImage).width(600).height(340).url()
: null;
{products.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-text-secondary">به زودی محصولات اضافه میشود.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product: Product, i) => {
const priceStr = formatPrice(product.price, product.currency);
return (
<AnimatedSection key={product._id} delay={i * 0.07}>
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
{/* Image or placeholder */}
{imageUrl ? (
<div className="relative h-44 overflow-hidden">
<Image src={imageUrl} alt={product.title} fill className="object-cover" />
</div>
) : (
<div className="flex h-44 items-center justify-center bg-surface-hover">
<span className="font-mono text-3xl text-accent opacity-20">//</span>
</div>
)}
<div className="flex flex-1 flex-col p-5">
<div className="mb-3 flex items-center gap-2">
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
{product.productType === "node-product" ? t("node_product") : t("digital")}
</Badge>
{product.featured && (
<span className="font-mono text-xs text-accent"></span>
)}
</div>
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
{product.description && (
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
{product.description}
</p>
return (
<AnimatedSection key={product.id} delay={i * 0.07}>
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
{/* Image */}
{product.coverImage ? (
<div className="relative h-44 overflow-hidden">
<Image src={product.coverImage} alt={product.title} fill className="object-cover" />
</div>
) : (
<div className="flex h-44 items-center justify-center bg-surface-hover">
<span className="font-mono text-3xl text-accent opacity-20">//</span>
</div>
)}
<div className="mt-auto flex items-center justify-between gap-3">
{product.price !== undefined && (
<span className="font-mono text-sm font-semibold text-accent">
{formatPrice(product.price, product.currency ?? "IRR")}
</span>
)}
{product.purchaseUrl ? (
<Button asChild size="sm" className="ms-auto">
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink size={13} />
{t("buy")}
</a>
</Button>
) : (
<Button size="sm" className="ms-auto">{t("buy")}</Button>
<div className="flex flex-1 flex-col p-5">
<div className="mb-3 flex items-center gap-2">
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
{product.productType === "node-product" ? t("node_product") : t("digital")}
</Badge>
{product.featured && <span className="font-mono text-xs text-accent"></span>}
</div>
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
{product.description && (
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
{product.description}
</p>
)}
<div className="mt-auto flex items-center justify-between gap-3">
{priceStr && (
<span className="font-mono text-sm font-semibold text-accent">{priceStr}</span>
)}
{product.purchaseUrl ? (
<Button asChild size="sm" className="ms-auto">
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink size={13} />
{t("buy")}
</a>
</Button>
) : (
<Button size="sm" className="ms-auto">{t("buy")}</Button>
)}
</div>
</div>
</div>
</div>
</AnimatedSection>
);
})}
</div>
</AnimatedSection>
);
})}
</div>
)}
</div>
);
}

View File

@@ -3,8 +3,7 @@ import { getTranslations } from "next-intl/server";
import { ExternalLink } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { sanityFetch } from "@/lib/sanity/client";
import { allUsesItemsQuery } from "@/lib/sanity/queries";
import { getAllUsesItems } from "@/lib/db/uses";
interface Props {
params: Promise<{ locale: string }>;
@@ -12,32 +11,13 @@ interface Props {
type UsesCategory = "development" | "design" | "marketing" | "productivity" | "hardware";
interface UsesItem {
_id: string;
title: string;
description?: string;
category: UsesCategory;
url?: string;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "uses" });
return { title: t("title"), description: t("subtitle") };
}
// Static fallback — shown until Sanity has data
const fallbackItems: UsesItem[] = [
{ _id: "1", title: "VS Code", description: "اصلی‌ترین ابزار کدنویسی‌ام", category: "development", url: "https://code.visualstudio.com" },
{ _id: "2", title: "Next.js", description: "فریم‌ورک React برای وب اپلیکیشن‌ها", category: "development", url: "https://nextjs.org" },
{ _id: "3", title: "Sanity", description: "هدلس CMS برای مدیریت محتوا", category: "development", url: "https://sanity.io" },
{ _id: "4", title: "Docker", description: "کانتینرایزیشن برای دپلوی", category: "development" },
{ _id: "5", title: "Figma", description: "طراحی UI/UX و هویت بصری", category: "design", url: "https://figma.com" },
{ _id: "6", title: "Linear", description: "مدیریت پروژه و تسک‌ها", category: "productivity", url: "https://linear.app" },
{ _id: "7", title: "Notion", description: "مستندسازی و یادداشت‌برداری", category: "productivity", url: "https://notion.so" },
{ _id: "8", title: "macOS", description: "سیستم‌عامل اصلی", category: "hardware" },
{ _id: "9", title: "Google Analytics + Umami", description: "آنالیتیکس سایت — هر دو برای مقایسه", category: "marketing" },
];
export const dynamic = "force-dynamic";
const categoryOrder: UsesCategory[] = ["development", "design", "marketing", "productivity", "hardware"];
@@ -46,10 +26,9 @@ export default async function UsesPage({ params }: Props) {
const t = await getTranslations({ locale, namespace: "uses" });
const tCat = await getTranslations({ locale, namespace: "uses.categories" });
const sanityItems = await sanityFetch<UsesItem[]>(allUsesItemsQuery);
const items = sanityItems && sanityItems.length > 0 ? sanityItems : fallbackItems;
const items = await getAllUsesItems();
const grouped = categoryOrder.reduce<Record<string, UsesItem[]>>((acc, cat) => {
const grouped = categoryOrder.reduce<Record<string, typeof items>>((acc, cat) => {
const catItems = items.filter((i) => i.category === cat);
if (catItems.length > 0) acc[cat] = catItems;
return acc;
@@ -73,7 +52,7 @@ export default async function UsesPage({ params }: Props) {
<div className="grid gap-3 sm:grid-cols-2">
{catItems.map((item) => (
<div
key={item._id}
key={item.id}
className="group flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4 transition-all hover:border-accent/20"
>
<div className="flex-1 min-w-0">
@@ -98,6 +77,9 @@ export default async function UsesPage({ params }: Props) {
</div>
</AnimatedSection>
))}
{Object.keys(grouped).length === 0 && (
<p className="text-center text-text-secondary py-10">هنوز ابزاری اضافه نشده.</p>
)}
</div>
</div>
);

View File

@@ -4,37 +4,36 @@ 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 { getProjectBySlug, getAllProjectSlugs } from "@/lib/db/projects";
import { TiptapRenderer } from "@/components/writing/TiptapRenderer";
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 }));
const slugs = await getAllProjectSlugs();
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 });
const project = await getProjectBySlug(slug);
if (!project) return {};
return {
title: project.seo?.title ?? project.title,
description: project.seo?.description ?? project.description,
title: project.seoTitle ?? project.title,
description: project.seoDesc ?? project.description ?? undefined,
};
}
export const dynamic = "force-dynamic";
export default async function ProjectPage({ params }: Props) {
const { locale, slug } = await params;
const [project, t, tWork] = await Promise.all([
sanityFetch<Project>(projectBySlugQuery, { slug }),
getProjectBySlug(slug),
getTranslations({ locale, namespace: "common" }),
getTranslations({ locale, namespace: "work" }),
]);
@@ -43,9 +42,6 @@ export default async function ProjectPage({ params }: Props) {
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">
@@ -60,10 +56,10 @@ export default async function ProjectPage({ params }: Props) {
</div>
{/* Cover */}
{coverUrl && (
{project.coverImage && (
<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" />
<Image src={project.coverImage} alt={project.title} fill className="object-cover" priority />
<div className="absolute inset-0 bg-linear-to-b from-transparent to-background/80" />
</div>
)}
@@ -96,23 +92,23 @@ export default async function ProjectPage({ params }: Props) {
</header>
{/* Body */}
{project.body && <PortableTextRenderer value={project.body as unknown[]} />}
{project.body && <TiptapRenderer html={project.body} />}
{/* 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 && (
{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}
{project.techStack.map((tech) => (
<span key={tech} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
{tech}
</span>
))}
</div>
</div>
)}
{project.toolsUsed && project.toolsUsed.length > 0 && (
{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">
@@ -127,18 +123,13 @@ export default async function ProjectPage({ params }: Props) {
</div>
{/* Gallery */}
{project.gallery && project.gallery.length > 0 && (
{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) => (
{project.gallery.map((imgUrl, 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"
/>
<Image src={imgUrl} alt={`Gallery ${i + 1}`} fill className="object-cover" />
</div>
))}
</div>

View File

@@ -2,9 +2,7 @@ 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";
import { getAllProjects } from "@/lib/db/projects";
interface Props {
params: Promise<{ locale: string }>;
@@ -16,11 +14,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: t("title"), description: t("subtitle") };
}
export const dynamic = "force-dynamic";
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 });
const projects = await getAllProjects(locale);
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
@@ -29,7 +28,7 @@ export default async function WorkPage({ params }: Props) {
<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 ?? []} />
<WorkListClient projects={projects} />
</div>
);
}

View File

@@ -4,49 +4,44 @@ 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 { getPostBySlug, getRelatedPosts, getAllPostSlugs, estimateReadTime } from "@/lib/db/posts";
import { TiptapRenderer } from "@/components/writing/TiptapRenderer";
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";
import { formatDate } from "@/lib/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 }));
const slugs = await getAllPostSlugs();
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 });
const post = await getPostBySlug(slug);
if (!post) return {};
return {
title: post.seo?.title ?? post.title,
description: post.seo?.description ?? post.excerpt,
title: post.seoTitle ?? post.title,
description: post.seoDesc ?? post.excerpt ?? undefined,
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() }]
: [],
title: post.seoTitle ?? post.title,
description: post.seoDesc ?? post.excerpt ?? undefined,
images: post.coverImage ? [{ url: post.coverImage }] : [],
},
};
}
export const dynamic = "force-dynamic";
export default async function PostPage({ params }: Props) {
const { locale, slug } = await params;
const [post, t, tCommon, tCat] = await Promise.all([
sanityFetch<Post>(postBySlugQuery, { slug }),
getPostBySlug(slug),
getTranslations({ locale, namespace: "writing" }),
getTranslations({ locale, namespace: "common" }),
getTranslations({ locale, namespace: "writing.categories" }),
@@ -54,20 +49,16 @@ export default async function PostPage({ params }: Props) {
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 related = await getRelatedPosts(locale, post.category, slug);
const readTime = post.body ? estimateReadTime(post.body) : null;
const postUrl = `https://biztaghavi.com/${locale}/writing/${slug}`;
const isRtl = locale === "fa";
const BackArrow = isRtl ? ArrowRight : ArrowLeft;
// Flatten tags
const tags = post.tags.map((pt) => pt.tag);
const flatRelated = related.map((p) => ({ ...p, tags: p.tags.map((pt) => pt.tag) }));
return (
<>
<ReadingProgress />
@@ -107,39 +98,34 @@ export default async function PostPage({ params }: Props) {
<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>
)}
<div className="mt-6 flex items-center justify-end gap-4">
<ShareButtons title={post.title} url={postUrl} />
</div>
</header>
{/* Cover image */}
{coverUrl && (
{post.coverImage && (
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
<Image
src={coverUrl}
alt={post.coverImage?.alt ?? post.title}
src={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 className="absolute inset-0 bg-linear-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[]} />}
{post.body && <TiptapRenderer html={post.body} />}
{/* Tags */}
{post.tags && post.tags.length > 0 && (
{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>
{tags.map((tag) => (
<Badge key={tag.id} variant="secondary">{tag.title}</Badge>
))}
</div>
)}
@@ -151,13 +137,13 @@ export default async function PostPage({ params }: Props) {
</div>
{/* Related posts */}
{related && related.length > 0 && (
{flatRelated.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)} />
{flatRelated.map((p) => (
<PostCard key={p.id} post={p} categoryLabel={tCat(p.category)} />
))}
</div>
</div>

View File

@@ -2,9 +2,7 @@ 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";
import { getAllPosts } from "@/lib/db/posts";
interface Props {
params: Promise<{ locale: string }>;
@@ -13,17 +11,21 @@ interface Props {
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"),
};
return { title: t("title"), description: t("subtitle") };
}
export const dynamic = "force-dynamic";
export default async function WritingPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "writing" });
const posts = await getAllPosts(locale);
const posts = await sanityFetch<Post[]>(allPostsQuery, { locale });
// Flatten tags for the component
const flatPosts = posts.map((p) => ({
...p,
tags: p.tags.map((pt) => pt.tag),
}));
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
@@ -32,7 +34,7 @@ export default async function WritingPage({ params }: Props) {
<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 ?? []} />
<WritingListClient posts={flatPosts} />
</div>
);
}

44
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { prisma } from "@/lib/db";
export const metadata: Metadata = {
title: { default: "Admin Panel", template: "%s — Admin" },
robots: { index: false, follow: false },
};
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
// Login page is exempt
return (
<div dir="rtl" className="min-h-screen bg-background font-persian">
{session.isLoggedIn ? (
<AuthedLayout username={session.username}>{children}</AuthedLayout>
) : (
children
)}
</div>
);
}
async function AuthedLayout({
children,
username,
}: {
children: React.ReactNode;
username?: string;
}) {
const unreadCount = await prisma.contactMessage.count({ where: { isRead: false } });
return (
<>
<AdminSidebar username={username} unreadCount={unreadCount} />
<main className="lg:pr-64 min-h-screen">
<div className="p-6 lg:p-8">{children}</div>
</main>
</>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Eye, EyeOff, Loader2, Lock } from "lucide-react";
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/admin/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { setError(data.error); return; }
router.push("/admin");
router.refresh();
} catch {
setError("خطای اتصال");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-accent/10">
<Lock size={24} className="text-accent" />
</div>
<h1 className="text-2xl font-bold text-text-primary">پنل مدیریت</h1>
<p className="mt-1 text-sm text-text-secondary">biztaghavi.com</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="rounded-2xl border border-border bg-surface p-6 space-y-4">
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
{error}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-primary">نام کاربری</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-primary">رمز عبور</label>
<div className="relative">
<input
type={showPass ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 pe-10 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => setShowPass(!showPass)}
className="absolute inset-y-0 end-3 flex items-center text-text-secondary hover:text-text-primary"
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-background transition-colors hover:bg-accent-hover disabled:opacity-60"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : "ورود"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useState, useEffect } from "react";
import { Loader2, Mail, MailOpen, Trash2, Circle } from "lucide-react";
import { formatDate } from "@/lib/types";
interface Message {
id: string; name: string; email: string;
subject: string | null; message: string;
isRead: boolean; createdAt: string;
}
export default function AdminMessagesPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Message | null>(null);
async function load() {
const res = await fetch("/api/admin/messages");
setMessages(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
async function markRead(id: string, isRead: boolean) {
await fetch(`/api/admin/messages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isRead }),
});
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, isRead } : m));
if (selected?.id === id) setSelected((p) => p ? { ...p, isRead } : p);
}
async function handleDelete(id: string) {
if (!confirm("پیام حذف شود؟")) return;
await fetch(`/api/admin/messages/${id}`, { method: "DELETE" });
setMessages((prev) => prev.filter((m) => m.id !== id));
if (selected?.id === id) setSelected(null);
}
function handleSelect(msg: Message) {
setSelected(msg);
if (!msg.isRead) markRead(msg.id, true);
}
const unread = messages.filter((m) => !m.isRead).length;
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">پیامها</h1>
{unread > 0 && <p className="text-sm text-accent mt-1">{unread} پیام خواندهنشده</p>}
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<Mail size={32} className="text-text-secondary mb-3" />
<p className="text-text-secondary">هنوز پیامی دریافت نشده</p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[1fr_1.4fr]">
{/* List */}
<div className="space-y-2">
{messages.map((msg) => (
<button
key={msg.id}
onClick={() => handleSelect(msg)}
className={`w-full rounded-xl border p-4 text-right transition-all hover:border-accent/30 ${selected?.id === msg.id ? "border-accent/40 bg-accent/5" : "border-border bg-surface"}`}
>
<div className="flex items-start gap-3">
<Circle size={8} className={`mt-2 shrink-0 ${!msg.isRead ? "fill-accent text-accent" : "fill-border text-border"}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={`font-medium ${!msg.isRead ? "text-text-primary" : "text-text-secondary"}`}>{msg.name}</span>
<span className="font-mono text-xs text-text-secondary shrink-0">{formatDate(msg.createdAt, "fa")}</span>
</div>
<div className="mt-0.5 text-sm text-text-secondary truncate">{msg.subject ?? msg.message}</div>
</div>
</div>
</button>
))}
</div>
{/* Detail */}
{selected ? (
<div className="rounded-xl border border-border bg-surface p-6 space-y-4 h-fit">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-text-primary">{selected.subject ?? "(بدون موضوع)"}</h2>
<div className="mt-1 flex flex-wrap gap-3 text-sm text-text-secondary">
<span>{selected.name}</span>
<a href={`mailto:${selected.email}`} className="text-accent hover:underline">{selected.email}</a>
</div>
<div className="mt-1 font-mono text-xs text-text-secondary">{formatDate(selected.createdAt, "fa")}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => markRead(selected.id, !selected.isRead)} title={selected.isRead ? "علامت‌گذاری به عنوان خوانده‌نشده" : "علامت‌گذاری به عنوان خوانده‌شده"} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
{selected.isRead ? <Mail size={14} /> : <MailOpen size={14} />}
</button>
<button onClick={() => handleDelete(selected.id)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors">
<Trash2 size={14} />
</button>
</div>
</div>
<div className="border-t border-border pt-4">
<p className="text-text-primary leading-relaxed whitespace-pre-wrap">{selected.message}</p>
</div>
<a href={`mailto:${selected.email}?subject=Re: ${selected.subject ?? ""}`} className="inline-flex items-center gap-2 rounded-lg bg-accent/10 border border-accent/20 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors">
<Mail size={14} /> پاسخ دادن
</a>
</div>
) : (
<div className="hidden lg:flex items-center justify-center rounded-xl border border-dashed border-border h-64">
<p className="text-sm text-text-secondary">یک پیام را انتخاب کنید</p>
</div>
)}
</div>
)}
</div>
);
}

132
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { FileText, Briefcase, ShoppingBag, Mail, Clock, Wrench } from "lucide-react";
import { formatDate } from "@/lib/types";
export const dynamic = "force-dynamic";
export default async function AdminDashboard() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const [postCount, projectCount, productCount, timelineCount, usesCount, unreadMessages, recentMessages, recentPosts] =
await Promise.all([
prisma.post.count(),
prisma.project.count(),
prisma.product.count(),
prisma.timelineEvent.count(),
prisma.usesItem.count(),
prisma.contactMessage.count({ where: { isRead: false } }),
prisma.contactMessage.findMany({ orderBy: { createdAt: "desc" }, take: 5 }),
prisma.post.findMany({ orderBy: { publishedAt: "desc" }, take: 5 }),
]);
const stats = [
{ label: "نوشته‌ها", value: postCount, icon: FileText, href: "/admin/posts", color: "text-blue-400" },
{ label: "پروژه‌ها", value: projectCount, icon: Briefcase, href: "/admin/projects", color: "text-purple-400" },
{ label: "محصولات", value: productCount, icon: ShoppingBag, href: "/admin/products", color: "text-green-400" },
{ label: "رویدادهای تایم‌لاین", value: timelineCount, icon: Clock, href: "/admin/timeline", color: "text-orange-400" },
{ label: "ابزارها", value: usesCount, icon: Wrench, href: "/admin/uses", color: "text-yellow-400" },
{
label: "پیام‌های خوانده‌نشده",
value: unreadMessages,
icon: Mail,
href: "/admin/messages",
color: unreadMessages > 0 ? "text-accent" : "text-text-secondary",
},
];
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">داشبورد</h1>
<p className="text-text-secondary mt-1">خوش آمدی، {session.username} 👋</p>
</div>
{/* Stats grid */}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 mb-10">
{stats.map((stat) => (
<a
key={stat.href}
href={stat.href}
className="group rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/30 hover:bg-surface-hover"
>
<div className="flex items-start justify-between">
<div>
<div className="text-3xl font-bold text-text-primary">{stat.value}</div>
<div className="mt-1 text-sm text-text-secondary">{stat.label}</div>
</div>
<div className={`mt-1 ${stat.color}`}>
<stat.icon size={22} />
</div>
</div>
</a>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Recent posts */}
<div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">آخرین نوشتهها</h2>
<a href="/admin/posts/new" className="text-xs text-accent hover:text-accent-hover">+ جدید</a>
</div>
{recentPosts.length === 0 ? (
<p className="text-sm text-text-secondary py-4 text-center">هنوز نوشتهای ندارید</p>
) : (
<div className="space-y-3">
{recentPosts.map((post) => (
<a
key={post.id}
href={`/admin/posts/${post.id}`}
className="flex items-start justify-between gap-3 rounded-lg p-2 hover:bg-surface-hover transition-colors"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary line-clamp-1">{post.title}</div>
<div className="mt-0.5 font-mono text-xs text-text-secondary">
{formatDate(post.publishedAt, "fa")} · {post.locale}
</div>
</div>
{post.featured && (
<span className="shrink-0 text-xs text-accent"></span>
)}
</a>
))}
</div>
)}
</div>
{/* Recent messages */}
<div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">پیامهای اخیر</h2>
<a href="/admin/messages" className="text-xs text-accent hover:text-accent-hover">مشاهده همه</a>
</div>
{recentMessages.length === 0 ? (
<p className="text-sm text-text-secondary py-4 text-center">هنوز پیامی دریافت نشده</p>
) : (
<div className="space-y-3">
{recentMessages.map((msg) => (
<a
key={msg.id}
href="/admin/messages"
className="flex items-start gap-3 rounded-lg p-2 hover:bg-surface-hover transition-colors"
>
<div className={`mt-1 h-2 w-2 shrink-0 rounded-full ${!msg.isRead ? "bg-accent" : "bg-border"}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{msg.name}</div>
<div className="text-xs text-text-secondary line-clamp-1">{msg.subject ?? msg.message}</div>
</div>
<div className="shrink-0 font-mono text-xs text-text-secondary">
{formatDate(msg.createdAt, "fa")}
</div>
</a>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Eye } from "lucide-react";
import { RichTextEditor } from "@/components/admin/RichTextEditor";
import { ImageUpload } from "@/components/admin/ImageUpload";
import { TagsInput } from "@/components/admin/TagsInput";
import { slugify } from "@/lib/types";
const CATEGORIES = [
{ value: "founder-notes", label: "یادداشت‌های بنیان‌گذار" },
{ value: "marketing-branding", label: "بازاریابی و برندینگ" },
{ value: "product-thinking", label: "تفکر محصول" },
{ value: "tech-builds", label: "ساخت‌های فنی" },
{ value: "business-experiments", label: "آزمایش‌های کسب‌وکار" },
{ value: "systems-productivity", label: "سیستم‌ها و بهره‌وری" },
];
interface PostFormData {
id?: string;
title: string;
slug: string;
locale: string;
category: string;
excerpt: string;
body: string;
coverImage: string;
featured: boolean;
publishedAt: string;
seoTitle: string;
seoDesc: string;
tagIds: string[];
}
interface Props {
initialData?: Partial<PostFormData>;
}
export function PostForm({ initialData }: Props) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState<PostFormData>({
title: "",
slug: "",
locale: "fa",
category: "founder-notes",
excerpt: "",
body: "",
coverImage: "",
featured: false,
publishedAt: new Date().toISOString().split("T")[0],
seoTitle: "",
seoDesc: "",
tagIds: [],
...initialData,
});
function set(key: keyof PostFormData, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleTitleChange(title: string) {
set("title", title);
if (!initialData?.id) {
set("slug", slugify(title));
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.title || !form.slug || !form.category) {
setError("عنوان، اسلاگ و دسته‌بندی الزامی است");
return;
}
setError(null);
setSaving(true);
try {
const method = initialData?.id ? "PUT" : "POST";
const url = initialData?.id ? `/api/admin/posts/${initialData.id}` : "/api/admin/posts";
const { tagIds, ...rest } = form;
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...rest,
publishedAt: new Date(form.publishedAt).toISOString(),
tagIds,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Save failed");
}
setSuccess(true);
setTimeout(() => {
router.push("/admin/posts");
}, 800);
} catch (e) {
setError(e instanceof Error ? e.message : "خطا در ذخیره");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-8">
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>
)}
{success && (
<div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد </div>
)}
{/* Basic info */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات اصلی</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
<input
type="text"
value={form.title}
onChange={(e) => handleTitleChange(e.target.value)}
required
className="admin-input w-full"
placeholder="عنوان نوشته را وارد کنید..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
<input
type="text"
value={form.slug}
onChange={(e) => set("slug", e.target.value)}
required
dir="ltr"
className="admin-input w-full font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
<option value="fa">فارسی</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی *</label>
<select value={form.category} onChange={(e) => set("category", e.target.value)} className="admin-input w-full">
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">تاریخ انتشار</label>
<input
type="date"
value={form.publishedAt}
onChange={(e) => set("publishedAt", e.target.value)}
dir="ltr"
className="admin-input w-full"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">چکیده</label>
<textarea
value={form.excerpt}
onChange={(e) => set("excerpt", e.target.value)}
rows={2}
className="admin-input w-full resize-none"
placeholder="خلاصه کوتاه از نوشته..."
/>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="featured"
checked={form.featured}
onChange={(e) => set("featured", e.target.checked)}
className="h-4 w-4 rounded border-border accent-accent"
/>
<label htmlFor="featured" className="text-sm text-text-primary">نوشته ویژه (Featured)</label>
</div>
</div>
{/* Cover image */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر کاور</h2>
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
</div>
{/* Tags */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تگها</h2>
<TagsInput value={form.tagIds} onChange={(ids) => set("tagIds", ids)} />
</div>
{/* Body */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">محتوا</h2>
<RichTextEditor
value={form.body}
onChange={(html) => set("body", html)}
placeholder="محتوای نوشته را اینجا بنویسید..."
/>
</div>
{/* SEO */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">SEO (اختیاری)</h2>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان SEO</label>
<input
type="text"
value={form.seoTitle}
onChange={(e) => set("seoTitle", e.target.value)}
className="admin-input w-full"
placeholder="پیش‌فرض: عنوان نوشته"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات SEO</label>
<textarea
value={form.seoDesc}
onChange={(e) => set("seoDesc", e.target.value)}
rows={2}
className="admin-input w-full resize-none"
placeholder="پیش‌فرض: چکیده نوشته"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3 justify-end sticky bottom-6">
<a
href={`/${form.locale}/writing/${form.slug}`}
target="_blank"
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<Eye size={14} />
پیشنمایش
</a>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{initialData?.id ? "ذخیره تغییرات" : "انتشار نوشته"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { notFound, redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { PostForm } from "../PostForm";
export const dynamic = "force-dynamic";
interface Props { params: Promise<{ id: string }> }
export default async function EditPostPage({ params }: Props) {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const { id } = await params;
const post = await prisma.post.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
});
if (!post) notFound();
const initialData = {
id: post.id,
title: post.title,
slug: post.slug,
locale: post.locale,
category: post.category,
excerpt: post.excerpt ?? "",
body: post.body ?? "",
coverImage: post.coverImage ?? "",
featured: post.featured,
publishedAt: post.publishedAt.toISOString().split("T")[0],
seoTitle: post.seoTitle ?? "",
seoDesc: post.seoDesc ?? "",
tagIds: post.tags.map((pt) => pt.tag.id),
};
return (
<div>
<div className="mb-6">
<a href="/admin/posts" className="text-sm text-text-secondary hover:text-accent"> برگشت به نوشتهها</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش نوشته</h1>
<p className="text-sm text-text-secondary font-mono mt-1">/{post.slug}</p>
</div>
<PostForm initialData={initialData} />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { PostForm } from "../PostForm";
export default async function NewPostPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
return (
<div>
<div className="mb-6">
<a href="/admin/posts" className="text-sm text-text-secondary hover:text-accent"> برگشت به نوشتهها</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">نوشته جدید</h1>
</div>
<PostForm />
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { formatDate } from "@/lib/types";
import { Plus, Pencil, Trash2, Star } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function AdminPostsPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const posts = await prisma.post.findMany({
orderBy: { publishedAt: "desc" },
include: { tags: { include: { tag: true } } },
});
const categoryColors: Record<string, string> = {
"founder-notes": "text-blue-400 bg-blue-400/10 border-blue-400/20",
"marketing-branding": "text-purple-400 bg-purple-400/10 border-purple-400/20",
"product-thinking": "text-green-400 bg-green-400/10 border-green-400/20",
"tech-builds": "text-orange-400 bg-orange-400/10 border-orange-400/20",
"business-experiments": "text-pink-400 bg-pink-400/10 border-pink-400/20",
"systems-productivity": "text-yellow-400 bg-yellow-400/10 border-yellow-400/20",
};
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">نوشتهها</h1>
<p className="text-sm text-text-secondary mt-1">{posts.length} نوشته</p>
</div>
<a
href="/admin/posts/new"
className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors"
>
<Plus size={16} />
نوشته جدید
</a>
</div>
{posts.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<p className="text-text-secondary">هنوز نوشتهای ندارید</p>
<a href="/admin/posts/new" className="mt-4 text-sm text-accent hover:text-accent-hover">
اولین نوشته را بنویسید
</a>
</div>
) : (
<div className="rounded-xl border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-hover text-right">
<th className="px-4 py-3 font-medium text-text-secondary">عنوان</th>
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">دستهبندی</th>
<th className="hidden px-4 py-3 font-medium text-text-secondary lg:table-cell">زبان</th>
<th className="hidden px-4 py-3 font-medium text-text-secondary lg:table-cell">تاریخ</th>
<th className="px-4 py-3 font-medium text-text-secondary">عملیات</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id} className="border-b border-border/50 hover:bg-surface-hover transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{post.featured && <Star size={12} className="text-accent shrink-0" />}
<span className="font-medium text-text-primary line-clamp-1">{post.title}</span>
</div>
<div className="mt-0.5 text-xs text-text-secondary font-mono">/{post.slug}</div>
</td>
<td className="hidden px-4 py-3 md:table-cell">
<span className={`rounded-full border px-2 py-0.5 text-xs ${categoryColors[post.category] ?? "text-text-secondary bg-surface-hover border-border"}`}>
{post.category}
</span>
</td>
<td className="hidden px-4 py-3 lg:table-cell">
<span className="font-mono text-xs text-text-secondary">{post.locale}</span>
</td>
<td className="hidden px-4 py-3 lg:table-cell">
<span className="font-mono text-xs text-text-secondary">
{formatDate(post.publishedAt, "fa")}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<a
href={`/admin/posts/${post.id}`}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
title="ویرایش"
>
<Pencil size={14} />
</a>
<DeletePostButton id={post.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function DeletePostButton({ id }: { id: string }) {
return (
<form
action={async () => {
"use server";
await fetch(`/api/admin/posts/${id}`, { method: "DELETE" });
}}
>
<button
type="button"
onClick={async () => {
if (!confirm("آیا مطمئن هستید؟")) return;
const res = await fetch(`/api/admin/posts/${id}`, { method: "DELETE" });
if (res.ok) window.location.reload();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"
title="حذف"
>
<Trash2 size={14} />
</button>
</form>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react";
import { ImageUpload } from "@/components/admin/ImageUpload";
import { slugify } from "@/lib/types";
interface ProductFormData {
id?: string;
title: string; slug: string; locale: string;
description: string; price: string; currency: string;
coverImage: string; purchaseUrl: string;
productType: string; featured: boolean; sortOrder: number;
seoTitle: string; seoDesc: string;
}
interface Props { initialData?: Partial<ProductFormData> }
export function ProductForm({ initialData }: Props) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState<ProductFormData>({
title: "", slug: "", locale: "fa", description: "",
price: "", currency: "IRR", coverImage: "", purchaseUrl: "",
productType: "digital", featured: false, sortOrder: 0,
seoTitle: "", seoDesc: "", ...initialData,
});
function set(key: keyof ProductFormData, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.title || !form.slug) { setError("عنوان و اسلاگ الزامی است"); return; }
setError(null); setSaving(true);
try {
const method = initialData?.id ? "PUT" : "POST";
const url = initialData?.id ? `/api/admin/products/${initialData.id}` : "/api/admin/products";
const res = await fetch(url, {
method, headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
price: form.price !== "" ? parseFloat(form.price) : null,
sortOrder: parseInt(String(form.sortOrder)) || 0,
}),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
setSuccess(true);
setTimeout(() => router.push("/admin/products"), 800);
} catch (e) { setError(e instanceof Error ? e.message : "خطا"); }
finally { setSaving(false); }
}
return (
<form onSubmit={handleSave} className="space-y-8">
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد </div>}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات محصول</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
<input type="text" value={form.title} onChange={(e) => { set("title", e.target.value); if (!initialData?.id) set("slug", slugify(e.target.value)); }} required className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
<input type="text" value={form.slug} onChange={(e) => set("slug", e.target.value)} required dir="ltr" className="admin-input w-full font-mono text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
<option value="fa">فارسی</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">نوع محصول</label>
<select value={form.productType} onChange={(e) => set("productType", e.target.value)} className="admin-input w-full">
<option value="digital">دیجیتال</option>
<option value="node-product">NODE Product</option>
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">قیمت</label>
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => set("price", e.target.value)} className="admin-input w-full" placeholder="0 = رایگان" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">ارز</label>
<select value={form.currency} onChange={(e) => set("currency", e.target.value)} className="admin-input w-full">
<option value="IRR">تومان</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک خرید</label>
<input type="url" value={form.purchaseUrl} onChange={(e) => set("purchaseUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://..." />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={3} className="admin-input w-full resize-none" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="featured-product" checked={form.featured} onChange={(e) => set("featured", e.target.checked)} className="h-4 w-4 rounded border-border accent-accent" />
<label htmlFor="featured-product" className="text-sm text-text-primary">محصول ویژه</label>
</div>
</div>
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر</h2>
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
</div>
<div className="flex items-center gap-3 justify-end">
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{initialData?.id ? "ذخیره تغییرات" : "ایجاد محصول"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,32 @@
import { notFound, redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ProductForm } from "../ProductForm";
export const dynamic = "force-dynamic";
interface Props { params: Promise<{ id: string }> }
export default async function EditProductPage({ params }: Props) {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const { id } = await params;
const product = await prisma.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<div className="mb-6">
<a href="/admin/products" className="text-sm text-text-secondary hover:text-accent"> برگشت به محصولات</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش محصول</h1>
</div>
<ProductForm initialData={{
id: product.id, title: product.title, slug: product.slug,
locale: product.locale, description: product.description ?? "",
price: product.price !== null ? String(product.price) : "",
currency: product.currency, coverImage: product.coverImage ?? "",
purchaseUrl: product.purchaseUrl ?? "", productType: product.productType,
featured: product.featured, sortOrder: product.sortOrder,
seoTitle: product.seoTitle ?? "", seoDesc: product.seoDesc ?? "",
}} />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { ProductForm } from "../ProductForm";
export default async function NewProductPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
return (
<div>
<div className="mb-6">
<a href="/admin/products" className="text-sm text-text-secondary hover:text-accent"> برگشت به محصولات</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">محصول جدید</h1>
</div>
<ProductForm />
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { Plus, Pencil, Trash2, Star, ExternalLink } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function AdminProductsPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const products = await prisma.product.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">محصولات</h1>
<p className="text-sm text-text-secondary mt-1">{products.length} محصول</p>
</div>
<a href="/admin/products/new" className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
<Plus size={16} /> محصول جدید
</a>
</div>
{products.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<p className="text-text-secondary">هنوز محصولی ندارید</p>
<a href="/admin/products/new" className="mt-4 text-sm text-accent">اولین محصول را اضافه کنید </a>
</div>
) : (
<div className="rounded-xl border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-hover text-right">
<th className="px-4 py-3 font-medium text-text-secondary">عنوان</th>
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">نوع</th>
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">قیمت</th>
<th className="px-4 py-3 font-medium text-text-secondary">عملیات</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-hover transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{p.featured && <Star size={12} className="text-accent shrink-0" />}
<span className="font-medium text-text-primary">{p.title}</span>
</div>
</td>
<td className="hidden px-4 py-3 md:table-cell">
<span className="font-mono text-xs text-text-secondary">{p.productType}</span>
</td>
<td className="hidden px-4 py-3 md:table-cell">
<span className="font-mono text-xs text-accent">
{p.price !== null && p.price !== undefined ? (p.price === 0 ? "رایگان" : `${p.price} ${p.currency}`) : "—"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<a href={`/admin/products/${p.id}`} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
<Pencil size={14} />
</a>
{p.purchaseUrl && (
<a href={p.purchaseUrl} target="_blank" rel="noopener noreferrer" className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
<ExternalLink size={14} />
</a>
)}
<button type="button" onClick={async () => { if (!confirm("حذف شود؟")) return; await fetch(`/api/admin/products/${p.id}`, { method: "DELETE" }); window.location.reload(); }} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Plus, X } from "lucide-react";
import { RichTextEditor } from "@/components/admin/RichTextEditor";
import { ImageUpload } from "@/components/admin/ImageUpload";
import { slugify } from "@/lib/types";
const PROJECT_TYPES = [
{ value: "product", label: "محصول" },
{ value: "brand-system", label: "سیستم برند" },
{ value: "open-source", label: "اوپن سورس" },
{ value: "creative-work", label: "کار خلاقانه" },
];
interface ProjectFormData {
id?: string;
title: string;
slug: string;
locale: string;
projectType: string;
description: string;
body: string;
coverImage: string;
gallery: string[];
techStack: string[];
toolsUsed: string[];
liveUrl: string;
githubUrl: string;
featured: boolean;
sortOrder: number;
seoTitle: string;
seoDesc: string;
}
interface Props { initialData?: Partial<ProjectFormData> }
export function ProjectForm({ initialData }: Props) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [newTech, setNewTech] = useState("");
const [newTool, setNewTool] = useState("");
const [form, setForm] = useState<ProjectFormData>({
title: "", slug: "", locale: "fa", projectType: "product",
description: "", body: "", coverImage: "", gallery: [],
techStack: [], toolsUsed: [], liveUrl: "", githubUrl: "",
featured: false, sortOrder: 0, seoTitle: "", seoDesc: "",
...initialData,
});
function set(key: keyof ProjectFormData, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.title || !form.slug) { setError("عنوان و اسلاگ الزامی است"); return; }
setError(null); setSaving(true);
try {
const method = initialData?.id ? "PUT" : "POST";
const url = initialData?.id ? `/api/admin/projects/${initialData.id}` : "/api/admin/projects";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
gallery: JSON.stringify(form.gallery),
techStack: JSON.stringify(form.techStack),
toolsUsed: JSON.stringify(form.toolsUsed),
}),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
setSuccess(true);
setTimeout(() => router.push("/admin/projects"), 800);
} catch (e) { setError(e instanceof Error ? e.message : "خطا در ذخیره"); }
finally { setSaving(false); }
}
function addToArray(key: "techStack" | "toolsUsed", val: string) {
if (val.trim() && !form[key].includes(val.trim())) {
set(key, [...form[key], val.trim()]);
}
}
function removeFromArray(key: "techStack" | "toolsUsed", val: string) {
set(key, form[key].filter((v) => v !== val));
}
return (
<form onSubmit={handleSave} className="space-y-8">
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد </div>}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات اصلی</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
<input type="text" value={form.title} onChange={(e) => { set("title", e.target.value); if (!initialData?.id) set("slug", slugify(e.target.value)); }} required className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
<input type="text" value={form.slug} onChange={(e) => set("slug", e.target.value)} required dir="ltr" className="admin-input w-full font-mono text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
<option value="fa">فارسی</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">نوع پروژه</label>
<select value={form.projectType} onChange={(e) => set("projectType", e.target.value)} className="admin-input w-full">
{PROJECT_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">ترتیب نمایش</label>
<input type="number" value={form.sortOrder} onChange={(e) => set("sortOrder", parseInt(e.target.value) || 0)} className="admin-input w-full" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیح کوتاه</label>
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={2} className="admin-input w-full resize-none" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک زنده</label>
<input type="url" value={form.liveUrl} onChange={(e) => set("liveUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://..." />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک GitHub</label>
<input type="url" value={form.githubUrl} onChange={(e) => set("githubUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://github.com/..." />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="featured-project" checked={form.featured} onChange={(e) => set("featured", e.target.checked)} className="h-4 w-4 rounded border-border accent-accent" />
<label htmlFor="featured-project" className="text-sm text-text-primary">پروژه ویژه (Featured)</label>
</div>
</div>
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر کاور</h2>
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
</div>
{/* Tech stack */}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تکنولوژیها و ابزارها</h2>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">تکنولوژیها</label>
<div className="flex flex-wrap gap-2 mb-2">
{form.techStack.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full border border-border bg-surface-hover px-2.5 py-1 text-xs text-text-secondary">
{t} <button type="button" onClick={() => removeFromArray("techStack", t)}><X size={10} /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input type="text" value={newTech} onChange={(e) => setNewTech(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addToArray("techStack", newTech); setNewTech(""); } }} placeholder="مثلاً: Next.js + Enter" className="admin-input flex-1" dir="ltr" />
<button type="button" onClick={() => { addToArray("techStack", newTech); setNewTech(""); }} className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<Plus size={14} />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">ابزارها</label>
<div className="flex flex-wrap gap-2 mb-2">
{form.toolsUsed.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full border border-border bg-surface-hover px-2.5 py-1 text-xs text-text-secondary">
{t} <button type="button" onClick={() => removeFromArray("toolsUsed", t)}><X size={10} /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input type="text" value={newTool} onChange={(e) => setNewTool(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addToArray("toolsUsed", newTool); setNewTool(""); } }} placeholder="مثلاً: Figma + Enter" className="admin-input flex-1" dir="ltr" />
<button type="button" onClick={() => { addToArray("toolsUsed", newTool); setNewTool(""); }} className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<Plus size={14} />
</button>
</div>
</div>
</div>
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">محتوا</h2>
<RichTextEditor value={form.body} onChange={(html) => set("body", html)} placeholder="توضیحات کامل پروژه..." />
</div>
<div className="flex items-center gap-3 justify-end">
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{initialData?.id ? "ذخیره تغییرات" : "ایجاد پروژه"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,39 @@
import { notFound, redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ProjectForm } from "../ProjectForm";
export const dynamic = "force-dynamic";
interface Props { params: Promise<{ id: string }> }
export default async function EditProjectPage({ params }: Props) {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const { id } = await params;
const project = await prisma.project.findUnique({ where: { id } });
if (!project) notFound();
function parseArr(val: string | null): string[] {
if (!val) return [];
try { return JSON.parse(val); } catch { return []; }
}
return (
<div>
<div className="mb-6">
<a href="/admin/projects" className="text-sm text-text-secondary hover:text-accent"> برگشت به پروژهها</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش پروژه</h1>
</div>
<ProjectForm initialData={{
id: project.id, title: project.title, slug: project.slug,
locale: project.locale, projectType: project.projectType,
description: project.description ?? "", body: project.body ?? "",
coverImage: project.coverImage ?? "", gallery: parseArr(project.gallery),
techStack: parseArr(project.techStack), toolsUsed: parseArr(project.toolsUsed),
liveUrl: project.liveUrl ?? "", githubUrl: project.githubUrl ?? "",
featured: project.featured, sortOrder: project.sortOrder,
seoTitle: project.seoTitle ?? "", seoDesc: project.seoDesc ?? "",
}} />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { ProjectForm } from "../ProjectForm";
export default async function NewProjectPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
return (
<div>
<div className="mb-6">
<a href="/admin/projects" className="text-sm text-text-secondary hover:text-accent"> برگشت به پروژهها</a>
<h1 className="mt-2 text-2xl font-bold text-text-primary">پروژه جدید</h1>
</div>
<ProjectForm />
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { Plus, Pencil, Trash2, Star, ExternalLink } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function AdminProjectsPage() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/admin/login");
const projects = await prisma.project.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">پروژهها</h1>
<p className="text-sm text-text-secondary mt-1">{projects.length} پروژه</p>
</div>
<a
href="/admin/projects/new"
className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors"
>
<Plus size={16} />
پروژه جدید
</a>
</div>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<p className="text-text-secondary">هنوز پروژهای ندارید</p>
<a href="/admin/projects/new" className="mt-4 text-sm text-accent hover:text-accent-hover">اولین پروژه را اضافه کنید </a>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div key={project.id} className="rounded-xl border border-border bg-surface p-4 space-y-3">
{project.coverImage && (
<div className="relative h-32 overflow-hidden rounded-lg bg-surface-hover">
<img src={project.coverImage} alt={project.title} className="h-full w-full object-cover" />
</div>
)}
<div>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-text-primary line-clamp-1">{project.title}</h3>
{project.featured && <Star size={12} className="text-accent shrink-0 mt-1" />}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="rounded-full bg-surface-hover border border-border px-2 py-0.5 font-mono text-xs text-text-secondary">
{project.projectType}
</span>
<span className="font-mono text-xs text-text-secondary">{project.locale}</span>
</div>
{project.description && (
<p className="mt-2 text-xs text-text-secondary line-clamp-2">{project.description}</p>
)}
</div>
<div className="flex items-center gap-2 border-t border-border pt-3">
<a
href={`/admin/projects/${project.id}`}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-border py-1.5 text-xs text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
>
<Pencil size={12} /> ویرایش
</a>
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
>
<ExternalLink size={12} />
</a>
)}
<button
type="button"
onClick={async () => {
if (!confirm("حذف شود؟")) return;
await fetch(`/api/admin/projects/${project.id}`, { method: "DELETE" });
window.location.reload();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
interface Settings {
siteTitle: string; description: string; currentStatus: string;
telegramChannel: string; socialGithub: string; socialLinkedin: string;
socialTwitter: string; socialTelegram: string; socialInstagram: string;
}
const empty: Settings = {
siteTitle: "Ali Taghavi", description: "", currentStatus: "",
telegramChannel: "", socialGithub: "", socialLinkedin: "",
socialTwitter: "", socialTelegram: "", socialInstagram: "",
};
export default function AdminSettingsPage() {
const [form, setForm] = useState<Settings>(empty);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/admin/settings")
.then((r) => r.json())
.then((data) => {
if (data && Object.keys(data).length > 0) setForm({ ...empty, ...data });
setLoading(false);
});
}, []);
function set(key: keyof Settings, value: string) {
setForm((p) => ({ ...p, [key]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setError(null); setSaving(true);
try {
const res = await fetch("/api/admin/settings", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
} catch (e) { setError(e instanceof Error ? e.message : "خطا"); }
finally { setSaving(false); }
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>;
return (
<div>
<h1 className="mb-6 text-2xl font-bold text-text-primary">تنظیمات سایت</h1>
<form onSubmit={handleSave} className="space-y-8 max-w-2xl">
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">تنظیمات ذخیره شد </div>}
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات سایت</h2>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">نام سایت</label>
<input type="text" value={form.siteTitle} onChange={(e) => set("siteTitle", e.target.value)} className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={3} className="admin-input w-full resize-none" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">وضعیت فعلی (در صفحه About نمایش داده میشود)</label>
<textarea value={form.currentStatus} onChange={(e) => set("currentStatus", e.target.value)} rows={2} className="admin-input w-full resize-none" placeholder="مثلاً: در حال ساخت HyperAccount..." />
</div>
</div>
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
<h2 className="font-semibold text-text-primary border-b border-border pb-3">شبکههای اجتماعی</h2>
<div className="grid gap-4 sm:grid-cols-2">
{[
{ key: "socialGithub", label: "GitHub", placeholder: "https://github.com/..." },
{ key: "socialLinkedin", label: "LinkedIn", placeholder: "https://linkedin.com/in/..." },
{ key: "socialTwitter", label: "Twitter/X", placeholder: "https://twitter.com/..." },
{ key: "socialTelegram", label: "Telegram", placeholder: "https://t.me/..." },
{ key: "socialInstagram", label: "Instagram", placeholder: "https://instagram.com/..." },
{ key: "telegramChannel", label: "کانال تلگرام", placeholder: "https://t.me/channel..." },
].map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-text-primary mb-1.5">{field.label}</label>
<input
type="url"
value={form[field.key as keyof Settings]}
onChange={(e) => set(field.key as keyof Settings, e.target.value)}
dir="ltr"
className="admin-input w-full"
placeholder={field.placeholder}
/>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
ذخیره تنظیمات
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, Pencil, Trash2, Loader2, Save, X } from "lucide-react";
import { formatDate } from "@/lib/types";
interface TimelineEvent {
id: string; title: string; date: string;
description: string; icon: string; category: string; sortOrder: number;
}
const CATEGORIES = [
{ value: "career", label: "شغلی" },
{ value: "product", label: "محصول" },
{ value: "personal", label: "شخصی" },
];
const emptyForm: Omit<TimelineEvent, "id"> = {
title: "", date: new Date().toISOString().split("T")[0],
description: "", icon: "🚀", category: "career", sortOrder: 0,
};
export default function AdminTimelinePage() {
const [events, setEvents] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [showNew, setShowNew] = useState(false);
const [form, setForm] = useState<Omit<TimelineEvent, "id">>(emptyForm);
const [saving, setSaving] = useState(false);
async function load() {
const res = await fetch("/api/admin/timeline");
setEvents(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
function startEdit(ev: TimelineEvent) {
setForm({ title: ev.title, date: new Date(ev.date).toISOString().split("T")[0], description: ev.description ?? "", icon: ev.icon ?? "🚀", category: ev.category, sortOrder: ev.sortOrder });
setEditingId(ev.id);
setShowNew(false);
}
async function handleSave() {
setSaving(true);
const body = { ...form, date: new Date(form.date).toISOString() };
if (editingId) {
await fetch(`/api/admin/timeline/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
} else {
await fetch("/api/admin/timeline", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
}
setSaving(false);
setEditingId(null);
setShowNew(false);
setForm(emptyForm);
await load();
}
async function handleDelete(id: string) {
if (!confirm("حذف شود؟")) return;
await fetch(`/api/admin/timeline/${id}`, { method: "DELETE" });
await load();
}
const showForm = showNew || editingId;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-text-primary">تایملاین</h1>
<button onClick={() => { setShowNew(true); setEditingId(null); setForm(emptyForm); }} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
<Plus size={16} /> رویداد جدید
</button>
</div>
{showForm && (
<div className="mb-6 rounded-xl border border-accent/20 bg-surface p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-text-primary">{editingId ? "ویرایش رویداد" : "رویداد جدید"}</h2>
<button onClick={() => { setShowNew(false); setEditingId(null); }} className="text-text-secondary hover:text-text-primary"><X size={16} /></button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان</label>
<input type="text" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">تاریخ</label>
<input type="date" value={form.date} onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))} dir="ltr" className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">آیکون (emoji)</label>
<input type="text" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} className="admin-input w-full" maxLength={4} />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی</label>
<select value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} className="admin-input w-full">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
<textarea value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} rows={2} className="admin-input w-full resize-none" />
</div>
</div>
<button onClick={handleSave} disabled={saving || !form.title} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
ذخیره
</button>
</div>
)}
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<p className="text-text-secondary">هنوز رویدادی ندارید</p>
</div>
) : (
<div className="space-y-3">
{events.map((ev) => (
<div key={ev.id} className="flex items-start gap-4 rounded-xl border border-border bg-surface p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-surface-hover text-lg shrink-0">{ev.icon ?? "●"}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-text-primary">{ev.title}</span>
<span className={`rounded-full border px-2 py-0.5 font-mono text-xs ${ev.category === "career" ? "text-accent border-accent/30" : ev.category === "product" ? "text-blue-400 border-blue-400/30" : "text-purple-400 border-purple-400/30"}`}>
{formatDate(new Date(ev.date), "fa")}
</span>
</div>
{ev.description && <p className="mt-1 text-sm text-text-secondary">{ev.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => startEdit(ev)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"><Pencil size={14} /></button>
<button onClick={() => handleDelete(ev.id)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
</div>
);
}

143
src/app/admin/uses/page.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, Pencil, Trash2, Loader2, Save, X } from "lucide-react";
interface UsesItem {
id: string; title: string; description: string;
category: string; url: string; sortOrder: number;
}
const CATEGORIES = [
{ value: "development", label: "توسعه" },
{ value: "design", label: "طراحی" },
{ value: "marketing", label: "بازاریابی" },
{ value: "productivity", label: "بهره‌وری" },
{ value: "hardware", label: "سخت‌افزار" },
];
const empty: Omit<UsesItem, "id"> = { title: "", description: "", category: "development", url: "", sortOrder: 0 };
export default function AdminUsesPage() {
const [items, setItems] = useState<UsesItem[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [showNew, setShowNew] = useState(false);
const [form, setForm] = useState<Omit<UsesItem, "id">>(empty);
const [saving, setSaving] = useState(false);
async function load() {
const res = await fetch("/api/admin/uses");
setItems(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handleSave() {
setSaving(true);
if (editingId) {
await fetch(`/api/admin/uses/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
} else {
await fetch("/api/admin/uses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
}
setSaving(false);
setEditingId(null);
setShowNew(false);
setForm(empty);
await load();
}
const grouped = CATEGORIES.reduce<Record<string, UsesItem[]>>((acc, cat) => {
const catItems = items.filter((i) => i.category === cat.value);
if (catItems.length > 0) acc[cat.value] = catItems;
return acc;
}, {});
const showForm = showNew || editingId !== null;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-text-primary">ابزارها و Uses</h1>
<button onClick={() => { setShowNew(true); setEditingId(null); setForm(empty); }} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
<Plus size={16} /> ابزار جدید
</button>
</div>
{showForm && (
<div className="mb-6 rounded-xl border border-accent/20 bg-surface p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-text-primary">{editingId ? "ویرایش ابزار" : "ابزار جدید"}</h2>
<button onClick={() => { setShowNew(false); setEditingId(null); }}><X size={16} className="text-text-secondary hover:text-text-primary" /></button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">نام ابزار</label>
<input type="text" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی</label>
<select value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} className="admin-input w-full">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
<input type="text" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} className="admin-input w-full" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک (اختیاری)</label>
<input type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} dir="ltr" className="admin-input w-full" placeholder="https://..." />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">ترتیب</label>
<input type="number" value={form.sortOrder} onChange={(e) => setForm((p) => ({ ...p, sortOrder: parseInt(e.target.value) || 0 }))} className="admin-input w-full" />
</div>
</div>
<button onClick={handleSave} disabled={saving || !form.title} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} ذخیره
</button>
</div>
)}
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
) : (
<div className="space-y-8">
{CATEGORIES.map((cat) => {
const catItems = grouped[cat.value];
if (!catItems) return null;
return (
<div key={cat.value}>
<h2 className="mb-3 flex items-center gap-3">
<span className="font-mono text-xs text-accent">// {cat.label}</span>
<span className="h-px flex-1 bg-border" />
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{catItems.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4">
<div className="flex-1 min-w-0">
<div className="font-medium text-text-primary">{item.title}</div>
{item.description && <p className="mt-0.5 text-sm text-text-secondary">{item.description}</p>}
{item.url && <a href={item.url} target="_blank" rel="noopener noreferrer" className="mt-1 block font-mono text-xs text-accent truncate hover:underline">{item.url}</a>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => { setForm({ title: item.title, description: item.description ?? "", category: item.category, url: item.url ?? "", sortOrder: item.sortOrder }); setEditingId(item.id); setShowNew(false); }} className="flex h-7 w-7 items-center justify-center rounded border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"><Pencil size={12} /></button>
<button onClick={async () => { if (!confirm("حذف؟")) return; await fetch(`/api/admin/uses/${item.id}`, { method: "DELETE" }); load(); }} className="flex h-7 w-7 items-center justify-center rounded border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"><Trash2 size={12} /></button>
</div>
</div>
))}
</div>
</div>
);
})}
{Object.keys(grouped).length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<p className="text-text-secondary">هنوز ابزاری ندارید</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
try {
const { username, password } = await req.json();
if (!username || !password) {
return Response.json({ error: "نام کاربری و رمز عبور الزامی است" }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { username } });
if (!user) {
return Response.json({ error: "نام کاربری یا رمز عبور اشتباه است" }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return Response.json({ error: "نام کاربری یا رمز عبور اشتباه است" }, { status: 401 });
}
const session = await getSession();
session.userId = user.id;
session.username = user.username;
session.isLoggedIn = true;
await session.save();
return Response.json({ success: true, username: user.username });
} catch (err) {
console.error("[auth/login]", err);
return Response.json({ error: "خطای سرور" }, { status: 500 });
}
}
export async function DELETE() {
const session = await getSession();
session.destroy();
return Response.json({ success: true });
}

View File

@@ -0,0 +1,33 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const { isRead } = await req.json();
const msg = await prisma.contactMessage.update({
where: { id },
data: { isRead },
});
return Response.json(msg);
} catch (err) {
console.error("[messages/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.contactMessage.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[messages/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,14 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET() {
try {
await requireAuth();
const messages = await prisma.contactMessage.findMany({
orderBy: { createdAt: "desc" },
});
return Response.json(messages);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}

View File

@@ -0,0 +1,57 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function GET(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const post = await prisma.post.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
});
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const body = await req.json();
const { tagIds, ...data } = body;
await prisma.postTag.deleteMany({ where: { postId: id } });
const post = await prisma.post.update({
where: { id },
data: {
...data,
tags: tagIds?.length
? { create: tagIds.map((tid: string) => ({ tagId: tid })) }
: undefined,
},
include: { tags: { include: { tag: true } } },
});
return Response.json(post);
} catch (err) {
console.error("[posts/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.post.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[posts/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
try {
await requireAuth();
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
const posts = await prisma.post.findMany({
where: { locale },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
});
return Response.json(posts);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const { tagIds, ...data } = body;
const post = await prisma.post.create({
data: {
...data,
tags: tagIds?.length
? { create: tagIds.map((id: string) => ({ tagId: id })) }
: undefined,
},
include: { tags: { include: { tag: true } } },
});
return Response.json(post, { status: 201 });
} catch (err) {
console.error("[posts/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,42 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function GET(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const product = await prisma.product.findUnique({ where: { id } });
if (!product) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(product);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const body = await req.json();
const product = await prisma.product.update({ where: { id }, data: body });
return Response.json(product);
} catch (err) {
console.error("[products/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.product.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[products/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
try {
await requireAuth();
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
const products = await prisma.product.findMany({
where: { locale },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return Response.json(products);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const product = await prisma.product.create({ data: body });
return Response.json(product, { status: 201 });
} catch (err) {
console.error("[products/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,42 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function GET(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const project = await prisma.project.findUnique({ where: { id } });
if (!project) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(project);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const body = await req.json();
const project = await prisma.project.update({ where: { id }, data: body });
return Response.json(project);
} catch (err) {
console.error("[projects/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.project.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[projects/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
try {
await requireAuth();
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
const projects = await prisma.project.findMany({
where: { locale },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return Response.json(projects);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const project = await prisma.project.create({ data: body });
return Response.json(project, { status: 201 });
} catch (err) {
console.error("[projects/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,30 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET() {
try {
await requireAuth();
const settings = await prisma.siteSettings.findUnique({ where: { id: "main" } });
return Response.json(settings ?? {});
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function PUT(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const { id, updatedAt, ...data } = body;
const settings = await prisma.siteSettings.upsert({
where: { id: "main" },
create: { id: "main", siteTitle: "Ali Taghavi", ...data },
update: data,
});
return Response.json(settings);
} catch (err) {
console.error("[settings/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
// One-time setup endpoint — creates the admin user and default settings.
// Disable or remove this route after first use in production.
export async function POST(req: Request) {
const setupKey = req.headers.get("x-setup-key");
if (setupKey !== process.env.SETUP_KEY) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
try {
const existing = await prisma.adminUser.findFirst();
if (existing) {
return Response.json({ error: "Admin already exists" }, { status: 409 });
}
const { username, password } = await req.json();
if (!username || !password || password.length < 8) {
return Response.json({ error: "Username and password (min 8 chars) required" }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.adminUser.create({ data: { username, password: hashed } });
await prisma.siteSettings.upsert({
where: { id: "main" },
create: {
id: "main",
siteTitle: "Ali Taghavi",
description: "Founder, builder, and strategist based in Tehran.",
currentStatus: "در حال ساخت HyperAccount — زیرساخت هویت برای محصولات ایرانی",
socialGithub: "https://github.com/alitaghavi",
socialLinkedin: "https://linkedin.com/in/alitaghavi",
socialTelegram: "https://t.me/alitaghavi",
},
update: {},
});
return Response.json({ success: true, userId: user.id });
} catch (err) {
console.error("[setup]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET() {
try {
await requireAuth();
const tags = await prisma.tag.findMany({ orderBy: { title: "asc" } });
return Response.json(tags);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const { title, slug } = await req.json();
const tag = await prisma.tag.upsert({
where: { slug },
create: { title, slug },
update: { title },
});
return Response.json(tag, { status: 201 });
} catch (err) {
console.error("[tags/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,30 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const body = await req.json();
const event = await prisma.timelineEvent.update({ where: { id }, data: body });
return Response.json(event);
} catch (err) {
console.error("[timeline/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.timelineEvent.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[timeline/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET() {
try {
await requireAuth();
const events = await prisma.timelineEvent.findMany({
orderBy: [{ sortOrder: "asc" }, { date: "desc" }],
});
return Response.json(events);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const event = await prisma.timelineEvent.create({ data: body });
return Response.json(event, { status: 201 });
} catch (err) {
console.error("[timeline/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,44 @@
import { type NextRequest } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { requireAuth } from "@/lib/auth";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function POST(req: NextRequest) {
try {
await requireAuth();
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) return Response.json({ error: "No file provided" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) {
return Response.json({ error: "File type not allowed" }, { status: 400 });
}
if (file.size > MAX_SIZE) {
return Response.json({ error: "File too large (max 10MB)" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split(".").pop() ?? "jpg";
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
const filename = `${timestamp}-${random}.${ext}`;
const uploadDir = path.join(process.cwd(), "public", "uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, filename), buffer);
return Response.json({ url: `/uploads/${filename}` });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
console.error("[upload]", err);
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,30 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
interface Params { params: Promise<{ id: string }> }
export async function PUT(req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
const body = await req.json();
const item = await prisma.usesItem.update({ where: { id }, data: body });
return Response.json(item);
} catch (err) {
console.error("[uses/PUT]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
try {
await requireAuth();
const { id } = await params;
await prisma.usesItem.delete({ where: { id } });
return Response.json({ success: true });
} catch (err) {
console.error("[uses/DELETE]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export async function GET() {
try {
await requireAuth();
const items = await prisma.usesItem.findMany({
orderBy: [{ category: "asc" }, { sortOrder: "asc" }],
});
return Response.json(items);
} catch {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: NextRequest) {
try {
await requireAuth();
const body = await req.json();
const item = await prisma.usesItem.create({ data: body });
return Response.json(item, { status: 201 });
} catch (err) {
console.error("[uses/POST]", err);
return Response.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
import { Resend } from "resend";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -11,23 +12,29 @@ export async function POST(req: NextRequest) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
await resend.emails.send({
from: "biztaghavi.com <noreply@biztaghavi.com>",
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
replyTo: email,
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
html: `
<div style="font-family: sans-serif; max-width: 600px;">
<p><strong>نام:</strong> ${name}</p>
<p><strong>ایمیل:</strong> ${email}</p>
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
<hr />
<p style="white-space: pre-wrap;">${message}</p>
</div>
`,
await prisma.contactMessage.create({
data: { name, email, subject: subject ?? null, message },
});
if (process.env.RESEND_API_KEY) {
await resend.emails.send({
from: "biztaghavi.com <noreply@biztaghavi.com>",
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
replyTo: email,
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
html: `
<div style="font-family: sans-serif; max-width: 600px;">
<p><strong>نام:</strong> ${name}</p>
<p><strong>ایمیل:</strong> ${email}</p>
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
<hr />
<p style="white-space: pre-wrap;">${message}</p>
</div>
`,
});
}
return Response.json({ success: true });
} catch (err) {
console.error("[contact]", err);

View File

@@ -8,6 +8,7 @@ export async function POST(req: NextRequest) {
return Response.json({ message: "Invalid secret" }, { status: 401 });
}
revalidateTag("sanity", "default");
revalidateTag("posts");
revalidateTag("projects");
return Response.json({ revalidated: true, now: Date.now() });
}

View File

@@ -1,16 +1,18 @@
import { sanityFetch } from "@/lib/sanity/client";
import { rssPostsQuery } from "@/lib/sanity/queries";
import { prisma } from "@/lib/db";
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 posts = await prisma.post.findMany({
where: { locale: "fa", publishedAt: { lte: new Date() } },
orderBy: { publishedAt: "desc" },
take: 20,
select: { id: true, title: true, slug: true, excerpt: true, publishedAt: true, category: true },
});
const items = (posts ?? [])
const items = posts
.map((post) => {
const url = `${SITE_URL}/writing/${post.slug.current}`;
const url = `${SITE_URL}/writing/${post.slug}`;
const date = post.publishedAt ? new Date(post.publishedAt).toUTCString() : "";
return `
<item>

View File

@@ -87,3 +87,48 @@ body::before {
/* Focus ring */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ── Rich text / Tiptap rendered content ─────────────────────────── */
.prose-custom { color: var(--text-primary); line-height: 1.8; }
.prose-custom h1 { font-size: 1.875rem; font-weight: 700; margin: 2.5rem 0 1rem; color: var(--text-primary); }
.prose-custom h2 { font-size: 1.5rem; font-weight: 700; margin: 2rem 0 0.75rem; color: var(--text-primary); }
.prose-custom h3 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: var(--text-primary); }
.prose-custom h4 { font-size: 1.125rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: var(--text-primary); }
.prose-custom p { margin-bottom: 1.25rem; color: rgba(250,250,250,0.9); }
.prose-custom p:last-child { margin-bottom: 0; }
.prose-custom ul { list-style: disc; padding-inline-start: 1.5rem; margin-bottom: 1.25rem; }
.prose-custom ol { list-style: decimal; padding-inline-start: 1.5rem; margin-bottom: 1.25rem; }
.prose-custom li { margin-bottom: 0.375rem; color: rgba(250,250,250,0.9); }
.prose-custom blockquote { border-inline-start: 2px solid var(--accent); padding-inline-start: 1.25rem; margin: 1.5rem 0; color: var(--text-secondary); font-style: italic; }
.prose-custom a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.prose-custom a:hover { color: var(--accent-hover); }
.prose-custom strong { font-weight: 600; color: var(--text-primary); }
.prose-custom em { font-style: italic; }
.prose-custom code { background: var(--surface); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.125rem 0.375rem; font-family: var(--font-mono, monospace); font-size: 0.875em; color: var(--accent); }
.prose-custom pre { background: var(--surface); border: 1px solid var(--border-color); border-radius: 10px; padding: 1rem; margin: 1.5rem 0; overflow-x: auto; }
.prose-custom pre code { background: none; border: none; padding: 0; font-size: 0.875rem; line-height: 1.6; color: var(--text-primary); }
.prose-custom hr { border: none; border-top: 1px solid var(--border-color); margin: 2rem 0; }
.prose-custom img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid var(--border-color); margin: 1.5rem 0; }
/* Tiptap editor placeholder */
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: inline-start;
color: var(--text-secondary);
pointer-events: none;
height: 0;
}
/* ── Admin input utility class ────────────────────────────────────── */
.admin-input {
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--surface-hover);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--text-primary);
transition: border-color 0.15s;
}
.admin-input::placeholder { color: var(--text-secondary); }
.admin-input:focus { outline: none; border-color: rgba(204,253,101,0.5); box-shadow: 0 0 0 3px rgba(204,253,101,0.08); }
.admin-input option { background: var(--surface); color: var(--text-primary); }

View File

@@ -1,10 +0,0 @@
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} />;
}