mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 15:38:09 +00:00
create post completed
This commit is contained in:
237
actions/posts.ts
Normal file
237
actions/posts.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { minioClient, bucketName } from "@/lib/minio"; // Import MinIO client
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
// Max file size (e.g., 5MB)
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
// Allowed image types
|
||||||
|
const ACCEPTED_IMAGE_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update schema: image is now optional File
|
||||||
|
const PostSchema = z.object({
|
||||||
|
title: z.string().min(3, "Title must be at least 3 characters"),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Slug must be at least 3 characters")
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||||
|
"Slug must be lowercase alphanumeric with hyphens"
|
||||||
|
),
|
||||||
|
content: z.string().min(10, "Content is too short"),
|
||||||
|
excerpt: z.string().optional(),
|
||||||
|
// Validate the image file
|
||||||
|
image: z
|
||||||
|
.instanceof(File)
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(file) => !file || file.size <= MAX_FILE_SIZE,
|
||||||
|
`Max image size is 5MB.`
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(file) => !file || ACCEPTED_IMAGE_TYPES.includes(file.type),
|
||||||
|
"Only .jpg, .jpeg, .png, .webp and .gif formats are supported."
|
||||||
|
),
|
||||||
|
published: z.boolean().optional(),
|
||||||
|
tags: z.string().optional(), // Handle tags if needed
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePostState = {
|
||||||
|
errors?: {
|
||||||
|
title?: string[];
|
||||||
|
slug?: string[];
|
||||||
|
content?: string[];
|
||||||
|
image?: string[]; // Changed from imageUrl
|
||||||
|
tags?: string[];
|
||||||
|
_form?: string[];
|
||||||
|
};
|
||||||
|
message?: string | null;
|
||||||
|
// Add a field to hold previous input values
|
||||||
|
previousInput?: {
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
tags?: string;
|
||||||
|
published?: boolean;
|
||||||
|
// We don't repopulate the file input for security reasons
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to upload to MinIO
|
||||||
|
async function uploadImageToMinio(file: File): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const fileExtension = file.name.split(".").pop();
|
||||||
|
const fileName = `${uuidv4()}.${fileExtension}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await minioClient.putObject(bucketName, fileName, buffer, buffer.length, {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
});
|
||||||
|
const baseUrl = process.env.MINIO_BASE_URL?.replace(/\/+$/, "");
|
||||||
|
const publicUrl = `${baseUrl}/${bucketName}/${fileName}`;
|
||||||
|
return publicUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MinIO Upload Error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPost(
|
||||||
|
prevState: CreatePostState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<CreatePostState> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
// Check for user ID specifically
|
||||||
|
return { errors: { _form: ["Authentication required."] }, message: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store raw form data for potential error return
|
||||||
|
const rawFormData = {
|
||||||
|
title: formData.get("title")?.toString(),
|
||||||
|
slug: formData.get("slug")?.toString(),
|
||||||
|
content: formData.get("content")?.toString(),
|
||||||
|
excerpt: formData.get("excerpt")?.toString(),
|
||||||
|
tags: formData.get("tags")?.toString(),
|
||||||
|
published: formData.get("published") === "on",
|
||||||
|
image: formData.get("image"), // Keep image separate for validation
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageFile = rawFormData.image;
|
||||||
|
const validatedFields = PostSchema.safeParse({
|
||||||
|
...rawFormData, // Spread raw data (excluding image)
|
||||||
|
// Pass the file object only if it's a File instance and has size > 0
|
||||||
|
image:
|
||||||
|
imageFile instanceof File && imageFile.size > 0 ? imageFile : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
console.error(
|
||||||
|
"Validation Errors:",
|
||||||
|
validatedFields.error.flatten().fieldErrors
|
||||||
|
);
|
||||||
|
// Return previous input along with errors
|
||||||
|
return {
|
||||||
|
errors: validatedFields.error.flatten().fieldErrors,
|
||||||
|
message: "Failed to create post due to validation errors.",
|
||||||
|
previousInput: {
|
||||||
|
// Exclude image file
|
||||||
|
title: rawFormData.title,
|
||||||
|
slug: rawFormData.slug,
|
||||||
|
content: rawFormData.content,
|
||||||
|
excerpt: rawFormData.excerpt,
|
||||||
|
tags: rawFormData.tags,
|
||||||
|
published: rawFormData.published,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, slug, content, excerpt, image, published, tags } =
|
||||||
|
validatedFields.data;
|
||||||
|
|
||||||
|
// --- Handle Image Upload ---
|
||||||
|
let uploadedImageUrl: string | null = null;
|
||||||
|
if (image) {
|
||||||
|
uploadedImageUrl = await uploadImageToMinio(image);
|
||||||
|
if (!uploadedImageUrl) {
|
||||||
|
// Return previous input on image upload failure
|
||||||
|
return {
|
||||||
|
errors: { image: ["Failed to upload image."] },
|
||||||
|
message: null,
|
||||||
|
previousInput: {
|
||||||
|
title: rawFormData.title,
|
||||||
|
slug: rawFormData.slug,
|
||||||
|
content: rawFormData.content,
|
||||||
|
excerpt: rawFormData.excerpt,
|
||||||
|
tags: rawFormData.tags,
|
||||||
|
published: rawFormData.published,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End Image Upload ---
|
||||||
|
|
||||||
|
// Check if slug is unique
|
||||||
|
const existingPost = await prisma.post.findUnique({ where: { slug } });
|
||||||
|
if (existingPost) {
|
||||||
|
// Return previous input on slug error
|
||||||
|
return {
|
||||||
|
errors: { slug: ["Slug already exists."] },
|
||||||
|
message: null,
|
||||||
|
previousInput: {
|
||||||
|
title: rawFormData.title,
|
||||||
|
slug: rawFormData.slug, // Keep the problematic slug to show the user
|
||||||
|
content: rawFormData.content,
|
||||||
|
excerpt: rawFormData.excerpt,
|
||||||
|
tags: rawFormData.tags,
|
||||||
|
published: rawFormData.published,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handle Tags ---
|
||||||
|
// Example: Split comma-separated tags and connect/create them
|
||||||
|
const tagConnections = tags
|
||||||
|
? await Promise.all(
|
||||||
|
tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tagName) =>
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { name: tagName },
|
||||||
|
update: {},
|
||||||
|
create: { name: tagName },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
// --- End Handle Tags ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.post.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
excerpt: excerpt || content.substring(0, 150) + "...",
|
||||||
|
imageUrl: uploadedImageUrl, // Store the MinIO URL
|
||||||
|
published: published ?? false,
|
||||||
|
authorId: session.user.id,
|
||||||
|
tags: {
|
||||||
|
// Connect tags
|
||||||
|
connect: tagConnections.map((tag) => ({ id: tag.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Database Error:", error);
|
||||||
|
// Return previous input on database error
|
||||||
|
return {
|
||||||
|
errors: { _form: ["Database Error: Failed to create post."] },
|
||||||
|
message: null,
|
||||||
|
previousInput: {
|
||||||
|
title: rawFormData.title,
|
||||||
|
slug: rawFormData.slug,
|
||||||
|
content: rawFormData.content,
|
||||||
|
excerpt: rawFormData.excerpt,
|
||||||
|
tags: rawFormData.tags,
|
||||||
|
published: rawFormData.published,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/tech-talk");
|
||||||
|
redirect(`/tech-talk/${slug}`);
|
||||||
|
}
|
||||||
40
app/(website)/tech-talk/create/page.tsx
Normal file
40
app/(website)/tech-talk/create/page.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import CreatePostForm from "@/components/CreatePostForm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
// Metadata for the page, preventing search engine indexing
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create New TechTalk Post | OMS",
|
||||||
|
description: "Create a new blog post for OMS TechTalk.",
|
||||||
|
robots: { index: false, follow: false }, // Don't index the creation page
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page component to render the creation form
|
||||||
|
export default async function CreateTechTalkPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// Protect the route: Redirect if not logged in
|
||||||
|
if (!session?.user) {
|
||||||
|
// Redirect to sign-in page, passing the current page as callbackUrl
|
||||||
|
// This ensures the user returns here after successful login
|
||||||
|
const callbackUrl = encodeURIComponent("/tech-talk/create");
|
||||||
|
redirect(`/api/auth/signin?callbackUrl=${callbackUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the page content if the user is authenticated
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
<div className="container mx-auto px-4 py-16 sm:py-20 lg:py-24">
|
||||||
|
<div className="max-w-3xl mx-auto bg-card p-6 sm:p-8 rounded-lg border border-border shadow-md">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-primary">
|
||||||
|
Create New TechTalk Post
|
||||||
|
</h1>
|
||||||
|
{/* Render the form component */}
|
||||||
|
<CreatePostForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/(website)/tech-talk/page.tsx
Normal file
124
app/(website)/tech-talk/page.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React from "react";
|
||||||
|
import BlogPostCard from "@/components/BlogPostCard";
|
||||||
|
import { prisma } from "@/lib/prisma"; // Import Prisma client
|
||||||
|
import { auth } from "@/auth"; // Import auth to check session
|
||||||
|
import Button from "@/components/ui/Button"; // Import Button component
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
excerpt?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
published: boolean;
|
||||||
|
authorId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fetch Posts ---
|
||||||
|
async function getPublishedPosts() {
|
||||||
|
try {
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
// select needed fields if not all
|
||||||
|
});
|
||||||
|
return posts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch posts:", error);
|
||||||
|
return []; // Return empty array on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SEO Metadata ---
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OMS TechTalk | Insights & Innovation",
|
||||||
|
description:
|
||||||
|
"Explore the latest insights, trends, and discussions on technology, innovation, and digital transformation from the experts at Owethu Managed Services (OMS).",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/tech-talk",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "OMS TechTalk | Insights & Innovation",
|
||||||
|
description: "Stay updated with tech insights from OMS.",
|
||||||
|
url: "https://oms.africa/tech-talk",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image-techtalk.jpg",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "OMS TechTalk Banner",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "OMS TechTalk | Insights & Innovation",
|
||||||
|
description: "Tech insights and articles from Owethu Managed Services.",
|
||||||
|
images: ["/og-image-techtalk.jpg"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Page Component ---
|
||||||
|
const TechTalkPage = async () => {
|
||||||
|
const posts = await getPublishedPosts();
|
||||||
|
const session = await auth(); // Get session info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
<div className="container mx-auto px-4 py-16 sm:py-20 lg:py-24">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="text-center mb-12 md:mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4 text-primary">
|
||||||
|
OMS TechTalk
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Insights, trends, and discussions on the latest in technology,
|
||||||
|
innovation, and digital transformation from the experts at OMS.
|
||||||
|
</p>
|
||||||
|
{/* Conditionally render Create Post button */}
|
||||||
|
{session?.user && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button href="/tech-talk/create" variant="primary">
|
||||||
|
Create New Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Blog Post Grid */}
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10">
|
||||||
|
{posts.map((post: Post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.id} // Use post ID as key
|
||||||
|
slug={post.slug}
|
||||||
|
title={post.title}
|
||||||
|
excerpt={post.excerpt ?? post.content.substring(0, 150) + "..."}
|
||||||
|
// Use imageUrl from DB or a default placeholder
|
||||||
|
imageUrl={post.imageUrl ?? "/posts/default-placeholder.jpg"} // Provide a default image
|
||||||
|
author={"OMS Team"} // Replace with actual author logic if available (e.g., post.user.name)
|
||||||
|
date={new Date(post.createdAt).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
No posts published yet. Check back soon!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: Add Pagination component here if needed */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechTalkPage;
|
||||||
@ -4,7 +4,7 @@ import { PacmanLoader } from "react-spinners";
|
|||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<PacmanLoader />
|
<PacmanLoader color="#e1c44a" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
33
auth.ts
33
auth.ts
@ -1,6 +1,39 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import GitHub from "next-auth/providers/github";
|
import GitHub from "next-auth/providers/github";
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import { prisma } from "./lib/prisma";
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
providers: [GitHub],
|
providers: [GitHub],
|
||||||
|
adapter: PrismaAdapter(prisma),
|
||||||
|
callbacks: {
|
||||||
|
// Add custom properties to the session object if needed
|
||||||
|
session: async ({ session, user }) => {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = user.id;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
// This ensures user profile data is properly updated
|
||||||
|
// when they sign in with GitHub or other providers
|
||||||
|
async signIn({ user, account, profile }) {
|
||||||
|
if (account?.provider === "github" && profile) {
|
||||||
|
try {
|
||||||
|
// Update or ensure GitHub-specific user data is saved
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
name: profile.name ?? user.name,
|
||||||
|
image: profile.avatar_url ?? user.image,
|
||||||
|
// Add any other profile data you want to store
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating user data:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
75
components/BlogPostCard.tsx
Normal file
75
components/BlogPostCard.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FiCalendar, FiUser, FiArrowRight } from "react-icons/fi";
|
||||||
|
|
||||||
|
type BlogPostCardProps = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
imageUrl: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
imageUrl,
|
||||||
|
author,
|
||||||
|
date,
|
||||||
|
}) => {
|
||||||
|
console.log("BlogPostCard Props:", { imageUrl });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/tech-talk/${slug}`} passHref>
|
||||||
|
<div className="group bg-card border border-border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 ease-in-out flex flex-col h-full transform hover:-translate-y-1">
|
||||||
|
{/* Image Container */}
|
||||||
|
<div className="relative w-full h-48 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={title}
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
className="transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
{/* Optional: Subtle overlay on hover */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="p-5 flex flex-col flex-grow">
|
||||||
|
<h3 className="text-xl font-semibold mb-2 text-foreground group-hover:text-primary transition-colors duration-200 line-clamp-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm mb-4 flex-grow line-clamp-3">
|
||||||
|
{excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Meta Info */}
|
||||||
|
<div className="text-xs text-muted-foreground/80 flex flex-wrap items-center gap-x-4 gap-y-1 mb-4">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<FiUser className="w-3 h-3 mr-1.5" />
|
||||||
|
{author}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<FiCalendar className="w-3 h-3 mr-1.5" />
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read More Link */}
|
||||||
|
<div className="mt-auto pt-2 border-t border-border/50">
|
||||||
|
<span className="inline-flex items-center text-sm font-medium text-primary group-hover:underline">
|
||||||
|
Read More
|
||||||
|
<FiArrowRight className="ml-1.5 w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogPostCard;
|
||||||
243
components/CreatePostForm.tsx
Normal file
243
components/CreatePostForm.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Import useActionState from react instead of useFormState from react-dom
|
||||||
|
import React, { useActionState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom"; // Keep this for useFormStatus
|
||||||
|
import { createPost, CreatePostState } from "@/actions/posts";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<Button type="submit" variant="primary" disabled={pending}>
|
||||||
|
{pending ? "Creating..." : "Create Post"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatePostForm() {
|
||||||
|
const initialState: CreatePostState = { message: null, errors: {} };
|
||||||
|
// Use useActionState from React
|
||||||
|
const [state, dispatch] = useActionState(createPost, initialState);
|
||||||
|
|
||||||
|
// Helper to get default value or empty string
|
||||||
|
const getPreviousInput = (
|
||||||
|
key: keyof NonNullable<CreatePostState["previousInput"]>
|
||||||
|
) => state.previousInput?.[key] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Remove encType="multipart/form-data"
|
||||||
|
<form action={dispatch} className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
// Use previous input as default value
|
||||||
|
defaultValue={getPreviousInput("title")}
|
||||||
|
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||||
|
aria-describedby="title-error"
|
||||||
|
/>
|
||||||
|
<div id="title-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.title &&
|
||||||
|
state.errors.title.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="slug"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Slug (URL Path)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||||
|
title="Lowercase letters, numbers, and hyphens only (e.g., my-cool-post)"
|
||||||
|
// Use previous input as default value
|
||||||
|
defaultValue={getPreviousInput("slug")}
|
||||||
|
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||||
|
aria-describedby="slug-error"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Lowercase letters, numbers, and hyphens only (e.g., my-cool-post).
|
||||||
|
</p>
|
||||||
|
<div id="slug-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.slug &&
|
||||||
|
state.errors.slug.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content (Textarea - consider a Markdown editor later) */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="content"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Content (Markdown supported)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
rows={10}
|
||||||
|
required
|
||||||
|
// Use previous input as default value
|
||||||
|
defaultValue={getPreviousInput("content")}
|
||||||
|
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||||
|
aria-describedby="content-error"
|
||||||
|
></textarea>
|
||||||
|
<div id="content-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.content &&
|
||||||
|
state.errors.content.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excerpt (Optional) */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="excerpt"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Excerpt (Optional short summary)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="excerpt"
|
||||||
|
name="excerpt"
|
||||||
|
rows={3}
|
||||||
|
// Use previous input as default value
|
||||||
|
defaultValue={getPreviousInput("excerpt")}
|
||||||
|
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||||
|
aria-describedby="excerpt-error"
|
||||||
|
></textarea>
|
||||||
|
<div id="excerpt-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.excerpt &&
|
||||||
|
state.errors.excerpt.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Upload (File Input) */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="image"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Featured Image (Optional, Max 5MB)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file" // Changed type to file
|
||||||
|
id="image"
|
||||||
|
name="image"
|
||||||
|
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-secondary file:text-secondary-foreground hover:file:bg-secondary/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||||
|
aria-describedby="image-error"
|
||||||
|
accept="image/jpeg, image/png, image/webp, image/gif" // Specify accepted types
|
||||||
|
/>
|
||||||
|
<div id="image-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.image &&
|
||||||
|
state.errors.image.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Input (Optional) */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="tags"
|
||||||
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>
|
||||||
|
Tags (Optional, comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
name="tags"
|
||||||
|
// Use previous input as default value
|
||||||
|
defaultValue={getPreviousInput("tags")}
|
||||||
|
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||||
|
aria-describedby="tags-error"
|
||||||
|
placeholder="e.g., cloud, ai, development"
|
||||||
|
/>
|
||||||
|
<div id="tags-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?.tags &&
|
||||||
|
state.errors.tags.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Published Checkbox */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="published"
|
||||||
|
name="published"
|
||||||
|
type="checkbox"
|
||||||
|
// Use previous input as default checked state
|
||||||
|
defaultChecked={state.previousInput?.published ?? false}
|
||||||
|
className="h-4 w-4 text-primary border-border rounded focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="published"
|
||||||
|
className="ml-2 block text-sm text-foreground"
|
||||||
|
>
|
||||||
|
Publish immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Form Error */}
|
||||||
|
<div id="form-error" aria-live="polite" aria-atomic="true">
|
||||||
|
{state.errors?._form &&
|
||||||
|
state.errors._form.map((error: string) => (
|
||||||
|
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{state.message && !state.errors?._form && (
|
||||||
|
// Display general message if no specific form error
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-sm ${
|
||||||
|
state.errors ? "text-destructive" : "text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -122,6 +122,12 @@ const HeaderClient = ({
|
|||||||
>
|
>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/tech-talk"
|
||||||
|
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
||||||
|
>
|
||||||
|
Tech Talk
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
||||||
|
|||||||
11
lib/minio.ts
Normal file
11
lib/minio.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Client } from "minio";
|
||||||
|
|
||||||
|
export const minioClient = new Client({
|
||||||
|
endPoint: process.env.MINIO_ENDPOINT || "localhost",
|
||||||
|
port: process.env.MINIO_PORT ? parseInt(process.env.MINIO_PORT) : 9000,
|
||||||
|
useSSL: true,
|
||||||
|
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: process.env.MINIO_SECRET_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bucketName = process.env.MINIO_BUCKET || "oms-website";
|
||||||
21
lib/prisma.ts
Normal file
21
lib/prisma.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
// PrismaClient is attached to the `global` object in development to prevent
|
||||||
|
// exhausting your database connection limit.
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var prisma: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
global.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? ["query", "error", "warn"]
|
||||||
|
: ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
global.prisma = prisma;
|
||||||
|
}
|
||||||
@ -2,7 +2,15 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
domains: ["avatars.githubusercontent.com"],
|
domains: ["avatars.githubusercontent.com", "storage.cvevolve.com"],
|
||||||
|
},
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "10mb", // Increase limit (e.g., to 10MB) - adjust as needed
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "2mb",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
977
package-lock.json
generated
977
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,9 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.9.0",
|
||||||
|
"@prisma/client": "^6.6.0",
|
||||||
|
"minio": "^8.0.5",
|
||||||
"next": "15.3.1",
|
"next": "15.3.1",
|
||||||
"next-auth": "^5.0.0-beta.26",
|
"next-auth": "^5.0.0-beta.26",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -16,7 +19,9 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-spinners": "^0.16.1",
|
"react-spinners": "^0.16.1",
|
||||||
"react-toggle-dark-mode": "^1.1.1"
|
"react-toggle-dark-mode": "^1.1.1",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@ -26,6 +31,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.1",
|
"eslint-config-next": "15.3.1",
|
||||||
|
"prisma": "^6.6.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
123
prisma/migrations/20250421143327_init/migration.sql
Normal file
123
prisma/migrations/20250421143327_init/migration.sql
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"emailVerified" TIMESTAMP(3),
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionToken" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationToken" (
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Post" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"excerpt" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_PostToTag" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_PostToTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Post_authorId_idx" ON "Post"("authorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_PostToTag_B_index" ON "_PostToTag"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PostToTag" ADD CONSTRAINT "_PostToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PostToTag" ADD CONSTRAINT "_PostToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
88
prisma/schema.prisma
Normal file
88
prisma/schema.prisma
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define User model to work with NextAuth
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
posts Post[] // Relation to posts
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextAuth models
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blog post model
|
||||||
|
model Post {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
excerpt String? @db.Text
|
||||||
|
imageUrl String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
authorId String
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
tags Tag[] // Relation to tags
|
||||||
|
|
||||||
|
@@index([authorId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags for categorizing posts
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
posts Post[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
BIN
public/posts/image1.jpg
Normal file
BIN
public/posts/image1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/posts/image2.jpg
Normal file
BIN
public/posts/image2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/posts/image3.jpg
Normal file
BIN
public/posts/image3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/posts/image4.jpg
Normal file
BIN
public/posts/image4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
9
types/next-auth.d.ts
vendored
Normal file
9
types/next-auth.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { DefaultSession } from "next-auth";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user