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 = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<PacmanLoader />
|
||||
<PacmanLoader color="#e1c44a" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
33
auth.ts
33
auth.ts
@ -1,6 +1,39 @@
|
||||
import NextAuth from "next-auth";
|
||||
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({
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
href="/tech-talk"
|
||||
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
||||
>
|
||||
Tech Talk
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
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 = {
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.9.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"minio": "^8.0.5",
|
||||
"next": "15.3.1",
|
||||
"next-auth": "^5.0.0-beta.26",
|
||||
"next-themes": "^0.4.6",
|
||||
@ -16,7 +19,9 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"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": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@ -26,6 +31,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"prisma": "^6.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"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