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:
32
src/lib/auth.ts
Normal file
32
src/lib/auth.ts
Normal 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
11
src/lib/db.ts
Normal 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
72
src/lib/db/posts.ts
Normal 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
13
src/lib/db/products.ts
Normal 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
47
src/lib/db/projects.ts
Normal 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
14
src/lib/db/settings.ts
Normal 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
8
src/lib/db/timeline.ts
Normal 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
8
src/lib/db/uses.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
84
src/lib/types.ts
Normal 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, "-");
|
||||
}
|
||||
Reference in New Issue
Block a user