create post completed

This commit is contained in:
libertyoms
2025-04-21 20:59:37 +02:00
parent 629da1b7e7
commit a8c6b5297b
21 changed files with 1973 additions and 37 deletions

237
actions/posts.ts Normal file
View 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}`);
}

View 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>
);
}

View 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;

View File

@ -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
View File

@ -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);
}
}
},
},
}); });

View 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;

View 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>
);
}

View File

@ -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
View 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
View 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;
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }

View 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;

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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
View File

@ -0,0 +1,9 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}