feat: add admin API routes for tags, timeline events, uploads, and uses management
Some checks failed
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
44
src/app/admin/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/app/admin/login/page.tsx
Normal file
100
src/app/admin/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/app/admin/messages/page.tsx
Normal file
124
src/app/admin/messages/page.tsx
Normal 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
132
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
src/app/admin/posts/PostForm.tsx
Normal file
262
src/app/admin/posts/PostForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/admin/posts/[id]/page.tsx
Normal file
47
src/app/admin/posts/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/posts/new/page.tsx
Normal file
18
src/app/admin/posts/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/app/admin/posts/page.tsx
Normal file
129
src/app/admin/posts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/app/admin/products/ProductForm.tsx
Normal file
128
src/app/admin/products/ProductForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/admin/products/[id]/page.tsx
Normal file
32
src/app/admin/products/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/admin/products/new/page.tsx
Normal file
17
src/app/admin/products/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/app/admin/products/page.tsx
Normal file
81
src/app/admin/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
src/app/admin/projects/ProjectForm.tsx
Normal file
199
src/app/admin/projects/ProjectForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/app/admin/projects/[id]/page.tsx
Normal file
39
src/app/admin/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/admin/projects/new/page.tsx
Normal file
17
src/app/admin/projects/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/app/admin/projects/page.tsx
Normal file
96
src/app/admin/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/app/admin/settings/page.tsx
Normal file
113
src/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/app/admin/timeline/page.tsx
Normal file
143
src/app/admin/timeline/page.tsx
Normal 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
143
src/app/admin/uses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/api/admin/auth/route.ts
Normal file
40
src/app/api/admin/auth/route.ts
Normal 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 });
|
||||
}
|
||||
33
src/app/api/admin/messages/[id]/route.ts
Normal file
33
src/app/api/admin/messages/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/admin/messages/route.ts
Normal file
14
src/app/api/admin/messages/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/admin/posts/[id]/route.ts
Normal file
57
src/app/api/admin/posts/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/admin/posts/route.ts
Normal file
40
src/app/api/admin/posts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/products/[id]/route.ts
Normal file
42
src/app/api/admin/products/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/products/route.ts
Normal file
29
src/app/api/admin/products/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/projects/[id]/route.ts
Normal file
42
src/app/api/admin/projects/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/projects/route.ts
Normal file
29
src/app/api/admin/projects/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/settings/route.ts
Normal file
30
src/app/api/admin/settings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/setup/route.ts
Normal file
45
src/app/api/admin/setup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/tags/route.ts
Normal file
29
src/app/api/admin/tags/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/timeline/[id]/route.ts
Normal file
30
src/app/api/admin/timeline/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/timeline/route.ts
Normal file
27
src/app/api/admin/timeline/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/admin/upload/route.ts
Normal file
44
src/app/api/admin/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/uses/[id]/route.ts
Normal file
30
src/app/api/admin/uses/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/uses/route.ts
Normal file
27
src/app/api/admin/uses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user