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

32
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
userId?: string;
username?: string;
isLoggedIn?: boolean;
}
const sessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: "biztaghavi_admin_session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function requireAuth(): Promise<SessionData> {
const session = await getSession();
if (!session.isLoggedIn) {
throw new Error("Unauthorized");
}
return session;
}

11
src/lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

72
src/lib/db/posts.ts Normal file
View File

@@ -0,0 +1,72 @@
import { prisma } from "@/lib/db";
import type { Post, Tag } from "@prisma/client";
export { formatDate } from "@/lib/types";
export type PostWithTags = Post & {
tags: { tag: Tag }[];
};
export async function getAllPosts(locale: string): Promise<PostWithTags[]> {
return prisma.post.findMany({
where: { locale },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
});
}
export async function getPostsByCategory(locale: string, category: string): Promise<PostWithTags[]> {
return prisma.post.findMany({
where: { locale, category },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
});
}
export async function getFeaturedPosts(locale: string, limit = 4): Promise<PostWithTags[]> {
return prisma.post.findMany({
where: { locale, featured: true },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
take: limit,
});
}
export async function getPostBySlug(slug: string): Promise<PostWithTags | null> {
return prisma.post.findUnique({
where: { slug },
include: { tags: { include: { tag: true } } },
});
}
export async function getRelatedPosts(
locale: string,
category: string,
excludeSlug: string,
limit = 3
): Promise<PostWithTags[]> {
return prisma.post.findMany({
where: { locale, category, NOT: { slug: excludeSlug } },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
take: limit,
});
}
export async function getAllPostSlugs(): Promise<{ slug: string; locale: string }[]> {
return prisma.post.findMany({ select: { slug: true, locale: true } });
}
export async function getLatestPosts(locale: string, limit = 5): Promise<PostWithTags[]> {
return prisma.post.findMany({
where: { locale },
include: { tags: { include: { tag: true } } },
orderBy: { publishedAt: "desc" },
take: limit,
});
}
export function estimateReadTime(html: string): number {
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const words = text.split(" ").filter(Boolean).length;
return Math.max(1, Math.ceil(words / 200));
}

13
src/lib/db/products.ts Normal file
View File

@@ -0,0 +1,13 @@
import { prisma } from "@/lib/db";
import type { Product } from "@prisma/client";
export async function getAllProducts(locale: string): Promise<Product[]> {
return prisma.product.findMany({
where: { locale },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
}
export async function getProductBySlug(slug: string): Promise<Product | null> {
return prisma.product.findUnique({ where: { slug } });
}

47
src/lib/db/projects.ts Normal file
View File

@@ -0,0 +1,47 @@
import { prisma } from "@/lib/db";
import type { Project } from "@prisma/client";
export type ProjectWithArrays = Omit<Project, "gallery" | "techStack" | "toolsUsed"> & {
gallery: string[];
techStack: string[];
toolsUsed: string[];
};
function parseArrayField(val: string | null): string[] {
if (!val) return [];
try { return JSON.parse(val); } catch { return []; }
}
export function parseProject(p: Project): ProjectWithArrays {
return {
...p,
gallery: parseArrayField(p.gallery),
techStack: parseArrayField(p.techStack),
toolsUsed: parseArrayField(p.toolsUsed),
};
}
export async function getAllProjects(locale: string): Promise<ProjectWithArrays[]> {
const rows = await prisma.project.findMany({
where: { locale },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return rows.map(parseProject);
}
export async function getProjectsByType(locale: string, projectType: string): Promise<ProjectWithArrays[]> {
const rows = await prisma.project.findMany({
where: { locale, projectType },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return rows.map(parseProject);
}
export async function getProjectBySlug(slug: string): Promise<ProjectWithArrays | null> {
const row = await prisma.project.findUnique({ where: { slug } });
return row ? parseProject(row) : null;
}
export async function getAllProjectSlugs(): Promise<{ slug: string; locale: string }[]> {
return prisma.project.findMany({ select: { slug: true, locale: true } });
}

14
src/lib/db/settings.ts Normal file
View File

@@ -0,0 +1,14 @@
import { prisma } from "@/lib/db";
import type { SiteSettings } from "@prisma/client";
export async function getSiteSettings(): Promise<SiteSettings | null> {
return prisma.siteSettings.findUnique({ where: { id: "main" } });
}
export async function upsertSiteSettings(data: Partial<Omit<SiteSettings, "id" | "updatedAt">>): Promise<SiteSettings> {
return prisma.siteSettings.upsert({
where: { id: "main" },
create: { id: "main", siteTitle: "Ali Taghavi", ...data },
update: data,
});
}

8
src/lib/db/timeline.ts Normal file
View File

@@ -0,0 +1,8 @@
import { prisma } from "@/lib/db";
import type { TimelineEvent } from "@prisma/client";
export async function getAllTimelineEvents(): Promise<TimelineEvent[]> {
return prisma.timelineEvent.findMany({
orderBy: [{ sortOrder: "asc" }, { date: "desc" }],
});
}

8
src/lib/db/uses.ts Normal file
View File

@@ -0,0 +1,8 @@
import { prisma } from "@/lib/db";
import type { UsesItem } from "@prisma/client";
export async function getAllUsesItems(): Promise<UsesItem[]> {
return prisma.usesItem.findMany({
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { title: "asc" }],
});
}

View File

@@ -1,18 +0,0 @@
import { createClient } from "next-sanity";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production",
apiVersion: "2024-01-01",
useCdn: process.env.NODE_ENV === "production",
token: process.env.SANITY_API_TOKEN,
});
export async function sanityFetch<T>(
query: string,
params: Record<string, unknown> = {}
): Promise<T> {
return client.fetch<T>(query, params, {
next: { tags: ["sanity"], revalidate: process.env.NODE_ENV === "development" ? 0 : 3600 },
});
}

View File

@@ -1,9 +0,0 @@
import imageUrlBuilder from "@sanity/image-url";
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { client } from "./client";
const builder = imageUrlBuilder(client);
export function urlFor(source: SanityImageSource) {
return builder.image(source);
}

View File

@@ -1,115 +0,0 @@
import { groq } from "next-sanity";
// ── Fragments ──────────────────────────────────────────────────────────────
const postFields = groq`
_id, title, slug, locale, category, excerpt, publishedAt, featured,
"coverImage": coverImage { asset->, alt },
"tags": tags[]->{ _id, title, slug },
"author": author->{ name, image }
`;
const projectFields = groq`
_id, title, slug, locale, projectType, description, featured,
techStack, toolsUsed, liveUrl, githubUrl,
"coverImage": coverImage { asset->, alt }
`;
// ── Posts ──────────────────────────────────────────────────────────────────
export const allPostsQuery = groq`
*[_type == "post" && locale == $locale] | order(publishedAt desc) {
${postFields}
}
`;
export const postsByCategoryQuery = groq`
*[_type == "post" && locale == $locale && category == $category] | order(publishedAt desc) {
${postFields}
}
`;
export const featuredPostsQuery = groq`
*[_type == "post" && locale == $locale && featured == true] | order(publishedAt desc)[0...4] {
${postFields}
}
`;
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
${postFields},
body,
"seo": seo { title, description, "ogImage": ogImage.asset-> }
}
`;
export const relatedPostsQuery = groq`
*[_type == "post" && locale == $locale && category == $category && slug.current != $slug] | order(publishedAt desc)[0...3] {
${postFields}
}
`;
export const allPostSlugsQuery = groq`
*[_type == "post"] { "slug": slug.current, locale }
`;
// ── Projects ───────────────────────────────────────────────────────────────
export const allProjectsQuery = groq`
*[_type == "project" && locale == $locale] | order(_createdAt desc) {
${projectFields}
}
`;
export const projectsByTypeQuery = groq`
*[_type == "project" && locale == $locale && projectType == $projectType] | order(_createdAt desc) {
${projectFields}
}
`;
export const projectBySlugQuery = groq`
*[_type == "project" && slug.current == $slug][0] {
${projectFields},
body,
"gallery": gallery[] { asset->, alt },
"seo": seo { title, description, "ogImage": ogImage.asset-> }
}
`;
export const allProjectSlugsQuery = groq`
*[_type == "project"] { "slug": slug.current, locale }
`;
// ── Site Settings ──────────────────────────────────────────────────────────
export const siteSettingsQuery = groq`
*[_type == "siteSettings"][0] {
siteTitle, description, currentStatus, telegramChannel, socialLinks,
"defaultOgImage": defaultOgImage.asset->
}
`;
// ── Products ───────────────────────────────────────────────────────────────
export const allProductsQuery = groq`
*[_type == "product" && locale == $locale] | order(_createdAt desc) {
_id, title, slug, description, price, currency, productType, purchaseUrl, featured,
"coverImage": coverImage { asset->, alt }
}
`;
// ── Uses items ─────────────────────────────────────────────────────────────
export const allUsesItemsQuery = groq`
*[_type == "usesItem"] | order(category asc, title asc) {
_id, title, description, category, url, "image": image { asset-> }
}
`;
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
export const rssPostsQuery = groq`
*[_type == "post" && locale == "fa"] | order(publishedAt desc)[0...20] {
_id, title, slug, excerpt, publishedAt, category
}
`;

View File

@@ -1,63 +0,0 @@
export type PostCategory =
| "founder-notes"
| "marketing-branding"
| "product-thinking"
| "tech-builds"
| "business-experiments"
| "systems-productivity";
export type ProjectType = "product" | "brand-system" | "open-source" | "creative-work";
export interface SanityImage {
asset: { url: string; metadata: { lqip: string; dimensions: { width: number; height: number } } };
alt?: string;
}
export interface Post {
_id: string;
title: string;
slug: { current: string };
locale: "fa" | "en";
category: PostCategory;
excerpt?: string;
publishedAt: string;
featured?: boolean;
coverImage?: SanityImage;
tags?: { _id: string; title: string; slug: { current: string } }[];
author?: { name: string; image?: SanityImage };
body?: unknown[];
seo?: { title?: string; description?: string; ogImage?: { url: string } };
}
export interface Project {
_id: string;
title: string;
slug: { current: string };
locale: "fa" | "en";
projectType: ProjectType;
description?: string;
featured?: boolean;
coverImage?: SanityImage;
gallery?: SanityImage[];
techStack?: string[];
toolsUsed?: string[];
liveUrl?: string;
githubUrl?: string;
body?: unknown[];
seo?: { title?: string; description?: string; ogImage?: { url: string } };
}
export interface SiteSettings {
siteTitle?: string;
description?: string;
currentStatus?: string;
telegramChannel?: string;
socialLinks?: {
github?: string;
linkedin?: string;
twitter?: string;
telegram?: string;
instagram?: string;
};
defaultOgImage?: { url: string };
}

View File

@@ -1,34 +0,0 @@
export function estimateReadTime(body: unknown[]): number {
if (!body) return 1;
const text = body
.filter((b: unknown) => (b as { _type: string })._type === "block")
.map((b: unknown) => {
const block = b as { children?: { text?: string }[] };
return block.children?.map((c) => c.text ?? "").join("") ?? "";
})
.join(" ");
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.ceil(words / 200));
}
export function formatPersianDate(dateStr: string): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(d);
} catch {
return dateStr;
}
}
export function formatDate(dateStr: string, locale: string): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
year: "numeric", month: "long", day: "numeric",
}).format(d);
} catch {
return dateStr;
}
}

84
src/lib/types.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { Post, Project, Product, TimelineEvent, UsesItem, SiteSettings, Tag } from "@prisma/client";
import type { PostWithTags } from "@/lib/db/posts";
import type { ProjectWithArrays } from "@/lib/db/projects";
export type PostCategory =
| "founder-notes"
| "marketing-branding"
| "product-thinking"
| "tech-builds"
| "business-experiments"
| "systems-productivity";
export type ProjectType = "product" | "brand-system" | "open-source" | "creative-work";
export type { Post, Project, Product, TimelineEvent, UsesItem, SiteSettings, Tag, PostWithTags, ProjectWithArrays };
export function formatDate(dateStr: string | Date, locale: string): string {
if (!dateStr) return "";
try {
const d = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(d);
} catch {
return String(dateStr);
}
}
export function formatPersianDate(date: string | Date): string {
if (!date) return "";
try {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(d);
} catch {
return String(date);
}
}
// Flat types used by frontend components (Sanity image objects replaced by URL strings)
export interface FlatPost {
id: string;
title: string;
slug: string;
locale: string;
category: string;
excerpt: string | null;
publishedAt: Date;
featured: boolean;
coverImage: string | null;
body: string | null;
tags: { id: string; title: string; slug: string }[];
seoTitle: string | null;
seoDesc: string | null;
}
export interface FlatProject {
id: string;
title: string;
slug: string;
locale: string;
projectType: string;
description: string | null;
coverImage: string | null;
gallery: string[];
techStack: string[];
toolsUsed: string[];
liveUrl: string | null;
githubUrl: string | null;
featured: boolean;
body: string | null;
seoTitle: string | null;
seoDesc: string | null;
}
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[\s_]+/g, "-")
.replace(/[^\w\u0600-\u06FF-]/g, "")
.replace(/-+/g, "-");
}