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

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

feat: enhance admin UI components for better user experience

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

feat: establish database interaction layer with Prisma

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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