diff --git a/.gitignore b/.gitignore
index 5ef6a52..67a2bf4 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/SOLUTION.md b/SOLUTION.md
new file mode 100644
index 0000000..82d9ec5
--- /dev/null
+++ b/SOLUTION.md
@@ -0,0 +1,42 @@
+# Blogging Application — Solution
+
+## Stack
+- Next.js 16 (App Router) + TypeScript
+- Prisma ORM + SQLite
+- Tailwind CSS
+- Custom auth: bcryptjs for password hashing, jose for JWTs in httpOnly cookies
+
+## Setup
+\`\`\`bash
+npm install
+npx prisma migrate dev
+npm run dev
+\`\`\`
+Create a `.env` file with:
+\`\`\`
+DATABASE_URL="file:./dev.db"
+JWT_SECRET="your-secret-here"
+\`\`\`
+
+## Features
+- Signup with full name, unique email, unique username, password (8+ chars, 1 special character)
+- Login / logout via httpOnly JWT cookie
+- Public homepage listing all posts, newest first, 8 per page
+- Per-user blog at `/[username]`, also paginated at 8 per page
+- Individual post at `/[username]/[slug]`
+- Comments: login required to post; newest first; form above the thread
+- Comment deletion: a user can delete their own comments; a post author can delete any comment on their post
+- Admin panel at `/admin` — list, create, edit, delete own posts
+
+## Design decisions
+
+**Slug uniqueness is scoped per author** (`@@unique([authorId, slug])`) rather than globally. Since post URLs are `/username/slug`, two different users can both have a post titled "Hello World" without collision. Duplicate titles by the same author get a numeric suffix.
+
+**Server components query the database directly.** Public pages (homepage, user blog, post page) are server components with no client-side fetching, so content is server-rendered. Only interactive parts (forms, delete buttons) are client components.
+
+**Authorization is enforced server-side, not in the UI.** Delete buttons are conditionally rendered, but the actual permission check lives in the API route — the UI check is convenience, not security.
+
+**Ownership checks use a two-key pattern:** every mutation looks the record up by ID and then verifies the owner ID from the JWT before acting. Post edit/delete returns 404 rather than 403 on an ownership mismatch, to avoid leaking whether a post exists.
+
+## Not implemented
+Extra credit items (social login, image uploads, WYSIWYG, CAPTCHA, 2FA) were not attempted, to focus on completing all core requirements.
\ No newline at end of file
diff --git a/app/[username]/[slug]/CommentSection.tsx b/app/[username]/[slug]/CommentSection.tsx
new file mode 100644
index 0000000..41f03f7
--- /dev/null
+++ b/app/[username]/[slug]/CommentSection.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+
+type Comment = {
+ id: string;
+ content: string;
+ createdAt: Date;
+ authorId: string;
+ author: { id: string; username: string; fullName: string };
+};
+
+export default function CommentSection({
+ postId,
+ postAuthorId,
+ comments,
+ currentUserId,
+}: {
+ postId: string;
+ postAuthorId: string;
+ comments: Comment[];
+ currentUserId: string | null;
+}) {
+ const router = useRouter();
+ const [content, setContent] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleAdd() {
+ if (!content.trim()) return;
+ setLoading(true);
+
+ await fetch("/api/comments", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ postId, content }),
+ });
+
+ setContent("");
+ setLoading(false);
+ router.refresh();
+ }
+
+ async function handleDelete(commentId: string) {
+ if (!confirm("Delete this comment?")) return;
+ await fetch(`/api/comments/${commentId}`, { method: "DELETE" });
+ router.refresh();
+ }
+
+ return (
+
+ Comments ({comments.length})
+
+ {currentUserId ? (
+
+
+ ) : (
+
+
+ Log in
+ {" "}
+ to leave a comment.
+
+ )}
+
+
+ {comments.length === 0 && (
+
No comments yet.
+ )}
+
+ {comments.map((comment) => {
+ const canDelete =
+ currentUserId !== null &&
+ (comment.authorId === currentUserId ||
+ postAuthorId === currentUserId);
+
+ return (
+
+
+
+ {comment.author.fullName}{" "}
+
+ @{comment.author.username}
+
+
+ {canDelete && (
+
handleDelete(comment.id)}
+ className="text-sm underline text-red-600"
+ >
+ Delete
+
+ )}
+
+
{comment.content}
+
+ {new Date(comment.createdAt).toLocaleString()}
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[username]/[slug]/page.tsx b/app/[username]/[slug]/page.tsx
new file mode 100644
index 0000000..938f1b6
--- /dev/null
+++ b/app/[username]/[slug]/page.tsx
@@ -0,0 +1,48 @@
+import { notFound } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+import CommentSection from "./CommentSection";
+
+export default async function PostPage({
+ params,
+}: {
+ params: Promise<{ username: string; slug: string }>;
+}) {
+ const { username, slug } = await params;
+
+ const user = await prisma.user.findUnique({ where: { username } });
+ if (!user) notFound();
+
+ const post = await prisma.post.findFirst({
+ where: { authorId: user.id, slug },
+ include: {
+ author: { select: { username: true, fullName: true } },
+ comments: {
+ orderBy: { createdAt: "desc" },
+ include: { author: { select: { id: true, username: true, fullName: true } } },
+ },
+ },
+ });
+
+ if (!post) notFound();
+
+ const currentUser = await getCurrentUser();
+
+ return (
+
+
{post.title}
+
+ by {post.author.fullName} · {new Date(post.createdAt).toLocaleDateString()}
+
+
+
{post.content}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[username]/page.tsx b/app/[username]/page.tsx
new file mode 100644
index 0000000..e81a8d4
--- /dev/null
+++ b/app/[username]/page.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+
+const POSTS_PER_PAGE = 8;
+
+export default async function UserBlogPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ username: string }>;
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const { username } = await params;
+ const sp = await searchParams;
+ const page = Number(sp.page) || 1;
+
+ const user = await prisma.user.findUnique({ where: { username } });
+ if (!user) notFound();
+
+ const [posts, totalPosts] = await Promise.all([
+ prisma.post.findMany({
+ where: { authorId: user.id },
+ orderBy: { createdAt: "desc" },
+ skip: (page - 1) * POSTS_PER_PAGE,
+ take: POSTS_PER_PAGE,
+ }),
+ prisma.post.count({ where: { authorId: user.id } }),
+ ]);
+
+ const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
+
+ return (
+
+
{user.fullName}
+
@{user.username}
+
+ {posts.length === 0 &&
No posts yet.
}
+
+
+ {posts.map((post) => (
+
+
+ {post.title}
+
+
+ {new Date(post.createdAt).toLocaleDateString()}
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+ {page > 1 && (
+
+ Previous
+
+ )}
+
+ Page {page} of {totalPages}
+
+ {page < totalPages && (
+
+ Next
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/DeletePostButton.tsx b/app/admin/DeletePostButton.tsx
new file mode 100644
index 0000000..f4cbb24
--- /dev/null
+++ b/app/admin/DeletePostButton.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+export default function DeletePostButton({ postId }: { postId: string }) {
+ const router = useRouter();
+
+ async function handleDelete() {
+ if (!confirm("Delete this post?")) return;
+
+ await fetch(`/api/posts/${postId}`, { method: "DELETE" });
+ router.refresh();
+ }
+
+ return (
+
+ Delete
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/edit/[id]/EditPostForm.tsx b/app/admin/edit/[id]/EditPostForm.tsx
new file mode 100644
index 0000000..1806125
--- /dev/null
+++ b/app/admin/edit/[id]/EditPostForm.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export default function EditPostForm({
+ post,
+}: {
+ post: { id: string; title: string; content: string };
+}) {
+ const router = useRouter();
+ const [title, setTitle] = useState(post.title);
+ const [content, setContent] = useState(post.content);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit() {
+ setError("");
+ setLoading(true);
+
+ const res = await fetch(`/api/posts/${post.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, content }),
+ });
+
+ const data = await res.json();
+ setLoading(false);
+
+ if (!res.ok) {
+ setError(data.error);
+ return;
+ }
+
+ router.push("/admin");
+ router.refresh();
+ }
+
+ return (
+
+
Edit Post
+
+ {error &&
{error}
}
+
+
+ setTitle(e.target.value)}
+ />
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/edit/[id]/page.tsx b/app/admin/edit/[id]/page.tsx
new file mode 100644
index 0000000..f57851e
--- /dev/null
+++ b/app/admin/edit/[id]/page.tsx
@@ -0,0 +1,20 @@
+import { redirect, notFound } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+import EditPostForm from "./EditPostForm";
+
+export default async function EditPostPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const user = await getCurrentUser();
+ if (!user) redirect("/login");
+
+ const { id } = await params;
+ const post = await prisma.post.findUnique({ where: { id } });
+
+ if (!post || post.authorId !== user.id) notFound();
+
+ return ;
+}
\ No newline at end of file
diff --git a/app/admin/new/page.tsx b/app/admin/new/page.tsx
new file mode 100644
index 0000000..fcf6adc
--- /dev/null
+++ b/app/admin/new/page.tsx
@@ -0,0 +1,64 @@
+"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export default function NewPostPage() {
+ const router = useRouter();
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit() {
+ setError("");
+ setLoading(true);
+
+ const res = await fetch("/api/posts", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, content }),
+ });
+
+ const data = await res.json();
+ setLoading(false);
+
+ if (!res.ok) {
+ setError(data.error);
+ return;
+ }
+
+ router.push("/admin");
+ router.refresh();
+ }
+
+ return (
+
+
New Post
+
+ {error &&
{error}
}
+
+
+ setTitle(e.target.value)}
+ />
+
+
+ );
+}
+
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..327bd97
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,59 @@
+
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+import DeletePostButton from "./DeletePostButton";
+
+export default async function AdminPage() {
+ const user = await getCurrentUser();
+ if (!user) redirect("/login");
+
+ const posts = await prisma.post.findMany({
+ where: { authorId: user.id },
+ orderBy: { createdAt: "desc" },
+ });
+
+ return (
+
+
+
My Posts
+
+ New Post
+
+
+
+ {posts.length === 0 &&
You have no posts yet.
}
+
+
+ {posts.map((post) => (
+
+
+
+ {post.title}
+
+
+ {new Date(post.createdAt).toLocaleDateString()}
+
+
+
+
+ Edit
+
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..83516b2
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import bcrypt from "bcryptjs";
+import { prisma } from "@/lib/prisma";
+import { createToken } from "@/lib/auth";
+
+export async function POST(request: Request) {
+ const { email, password } = await request.json();
+
+ const user = await prisma.user.findUnique({ where: { email } });
+
+ if (!user) {
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+ }
+
+ const valid = await bcrypt.compare(password, user.password);
+
+ if (!valid) {
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+ }
+
+ const token = await createToken(user.id);
+
+ const response = NextResponse.json({
+ id: user.id,
+ username: user.username,
+ });
+
+ response.cookies.set("token", token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 7,
+ path: "/",
+ });
+
+ return response;
+}
\ No newline at end of file
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..183899b
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from "next/server";
+
+export async function POST() {
+ const response = NextResponse.json({ ok: true });
+ response.cookies.set("token", "", { maxAge: 0, path: "/" });
+ return response;
+}
diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts
new file mode 100644
index 0000000..7e115f6
--- /dev/null
+++ b/app/api/auth/signup/route.ts
@@ -0,0 +1,70 @@
+import { NextResponse } from "next/server";
+import bcrypt from "bcryptjs";
+import { prisma } from "@/lib/prisma";
+import { createToken } from "@/lib/auth";
+
+export async function POST(request: Request) {
+ const { fullName, email, username, password } = await request.json();
+
+ if (!fullName || !email || !username || !password) {
+ return NextResponse.json(
+ { error: "All fields are required" },
+ { status: 400 }
+ );
+ }
+
+ if (password.length < 8) {
+ return NextResponse.json(
+ { error: "Password must be at least 8 characters" },
+ { status: 400 }
+ );
+ }
+
+ if (!/[^A-Za-z0-9]/.test(password)) {
+ return NextResponse.json(
+ { error: "Password must contain at least 1 special character" },
+ { status: 400 }
+ );
+ }
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
+ return NextResponse.json(
+ { error: "Username can only contain letters, numbers, - and _" },
+ { status: 400 }
+ );
+ }
+
+ const existing = await prisma.user.findFirst({
+ where: { OR: [{ email }, { username }] },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: "Email or username already taken" },
+ { status: 409 }
+ );
+ }
+
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ const user = await prisma.user.create({
+ data: { fullName, email, username, password: hashedPassword },
+ });
+
+ const token = await createToken(user.id);
+
+ const response = NextResponse.json({
+ id: user.id,
+ username: user.username,
+ });
+
+ response.cookies.set("token", token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 7,
+ path: "/",
+ });
+
+ return response;
+}
diff --git a/app/api/comments/[id]/route.ts b/app/api/comments/[id]/route.ts
new file mode 100644
index 0000000..b97af2d
--- /dev/null
+++ b/app/api/comments/[id]/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return NextResponse.json({ error: "Not logged in" }, { status: 401 });
+ }
+
+ const { id } = await params;
+
+ const comment = await prisma.comment.findUnique({
+ where: { id },
+ include: { post: { select: { authorId: true } } },
+ });
+
+ if (!comment) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const isCommentAuthor = comment.authorId === user.id;
+ const isPostAuthor = comment.post.authorId === user.id;
+
+ if (!isCommentAuthor && !isPostAuthor) {
+ return NextResponse.json({ error: "Not allowed" }, { status: 403 });
+ }
+
+ await prisma.comment.delete({ where: { id } });
+ return NextResponse.json({ ok: true });
+}
\ No newline at end of file
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
new file mode 100644
index 0000000..50c36f7
--- /dev/null
+++ b/app/api/comments/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+
+export async function POST(request: Request) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return NextResponse.json({ error: "Not logged in" }, { status: 401 });
+ }
+
+ const { postId, content } = await request.json();
+
+ if (!content?.trim()) {
+ return NextResponse.json({ error: "Comment cannot be empty" }, { status: 400 });
+ }
+
+ const post = await prisma.post.findUnique({ where: { id: postId } });
+ if (!post) {
+ return NextResponse.json({ error: "Post not found" }, { status: 404 });
+ }
+
+ const comment = await prisma.comment.create({
+ data: { content, postId, authorId: user.id },
+ });
+
+ return NextResponse.json(comment);
+}
\ No newline at end of file
diff --git a/app/api/posts/[id]/route.ts b/app/api/posts/[id]/route.ts
new file mode 100644
index 0000000..15fc877
--- /dev/null
+++ b/app/api/posts/[id]/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+import { slugify } from "@/lib/slug";
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return NextResponse.json({ error: "Not logged in" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const { title, content } = await request.json();
+
+ const post = await prisma.post.findUnique({ where: { id } });
+
+ if (!post || post.authorId !== user.id) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const updated = await prisma.post.update({
+ where: { id },
+ data: { title, content, slug: slugify(title) },
+ });
+
+ return NextResponse.json(updated);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return NextResponse.json({ error: "Not logged in" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const post = await prisma.post.findUnique({ where: { id } });
+
+ if (!post || post.authorId !== user.id) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ await prisma.post.delete({ where: { id } });
+ return NextResponse.json({ ok: true });
+}
\ No newline at end of file
diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts
new file mode 100644
index 0000000..d518f4f
--- /dev/null
+++ b/app/api/posts/route.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { getCurrentUser } from "@/lib/auth";
+import { slugify } from "@/lib/slug";
+
+export async function POST(request: Request) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return NextResponse.json({ error: "Not logged in" }, { status: 401 });
+ }
+
+ const { title, content } = await request.json();
+
+ if (!title || !content) {
+ return NextResponse.json(
+ { error: "Title and content are required" },
+ { status: 400 }
+ );
+ }
+
+ let slug = slugify(title);
+ let counter = 1;
+
+ while (
+ await prisma.post.findFirst({
+ where: { authorId: user.id, slug },
+ })
+ ) {
+ slug = `${slugify(title)}-${counter}`;
+ counter++;
+ }
+
+ const post = await prisma.post.create({
+ data: { title, content, slug, authorId: user.id },
+ });
+
+ return NextResponse.json(post);
+}
\ No newline at end of file
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..c48a81a
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [form, setForm] = useState({ email: "", password: "" });
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit() {
+ setError("");
+ setLoading(true);
+
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(form),
+ });
+
+ const data = await res.json();
+ setLoading(false);
+
+ if (!res.ok) {
+ setError(data.error);
+ return;
+ }
+
+ router.push("/admin");
+ router.refresh();
+ }
+
+ return (
+
+
Log in
+
+ {error &&
{error}
}
+
+
+ setForm({ ...form, email: e.target.value })}
+ />
+ setForm({ ...form, password: e.target.value })}
+ />
+
+ {loading ? "Logging in..." : "Log in"}
+
+
+
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
index c887311..9fd9818 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,69 +1,71 @@
-import Image from "next/image";
+import Link from "next/link";
+import { prisma } from "@/lib/prisma";
+
+const POSTS_PER_PAGE = 8;
+
+export default async function HomePage({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const params = await searchParams;
+ const page = Number(params.page) || 1;
+
+ const [posts, totalPosts] = await Promise.all([
+ prisma.post.findMany({
+ orderBy: { createdAt: "desc" },
+ skip: (page - 1) * POSTS_PER_PAGE,
+ take: POSTS_PER_PAGE,
+ include: { author: { select: { username: true, fullName: true } } },
+ }),
+ prisma.post.count(),
+ ]);
+
+ const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
-export default function Home() {
return (
-
-
-
-
);
-}
+}
\ No newline at end of file
diff --git a/app/signup/page.tsx b/app/signup/page.tsx
new file mode 100644
index 0000000..99f76b3
--- /dev/null
+++ b/app/signup/page.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+
+export default function SignupPage() {
+ const router = useRouter();
+ const [form, setForm] = useState({
+ fullName: "",
+ email: "",
+ username: "",
+ password: "",
+ });
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit() {
+ setError("");
+ setLoading(true);
+
+ const res = await fetch("/api/auth/signup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(form),
+ });
+
+ const data = await res.json();
+ setLoading(false);
+
+ if (!res.ok) {
+ setError(data.error);
+ return;
+ }
+
+ router.push("/admin");
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..4044ecd
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,37 @@
+import { SignJWT, jwtVerify } from "jose";
+import { cookies } from "next/headers";
+import { prisma } from "./prisma";
+
+const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "dev-secret-change-me"
+);
+
+export async function createToken(userId: string) {
+ return await new SignJWT({ userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("7d")
+ .sign(secret);
+}
+
+export async function verifyToken(token: string) {
+ try {
+ const { payload } = await jwtVerify(token, secret);
+ return payload as { userId: string };
+ } catch {
+ return null;
+ }
+}
+
+export async function getCurrentUser() {
+ const cookieStore = await cookies();
+ const token = cookieStore.get("token")?.value;
+ if (!token) return null;
+
+ const payload = await verifyToken(token);
+ if (!payload) return null;
+
+ return await prisma.user.findUnique({
+ where: { id: payload.userId },
+ select: { id: true, username: true, fullName: true },
+ });
+}
\ No newline at end of file
diff --git a/lib/prisma.ts b/lib/prisma.ts
new file mode 100644
index 0000000..8763daa
--- /dev/null
+++ b/lib/prisma.ts
@@ -0,0 +1,11 @@
+import { PrismaClient } from "@prisma/client";
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined;
+};
+
+export const prisma = globalForPrisma.prisma ?? new PrismaClient();
+
+if (process.env.NODE_ENV !== "production") {
+ globalForPrisma.prisma = prisma;
+}
\ No newline at end of file
diff --git a/lib/slug.ts b/lib/slug.ts
new file mode 100644
index 0000000..6cc3b6d
--- /dev/null
+++ b/lib/slug.ts
@@ -0,0 +1,8 @@
+export function slugify(title: string) {
+ return title
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-");
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index dc9aa2d..189b6f3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,17 @@
"name": "nextjs-developer-exercise",
"version": "0.1.0",
"dependencies": {
+ "@prisma/client": "^6.19.3",
+ "bcryptjs": "^3.0.3",
+ "jose": "^6.2.9",
"next": "16.3.0",
+ "prisma": "^6.19.3",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1317,6 +1322,85 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@prisma/client": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
+ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/config": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
+ "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.21.0",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
+ "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
+ "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.3",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
+ "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
+ "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1324,6 +1408,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1681,6 +1771,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2656,6 +2753,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -2714,6 +2820,34 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2811,6 +2945,30 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2844,6 +3002,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2959,6 +3132,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -2995,6 +3177,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "license": "MIT"
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3018,6 +3212,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3033,6 +3239,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/effect": {
+ "version": "3.21.0",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
+ "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.402",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
@@ -3047,6 +3263,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.24.5",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
@@ -3689,6 +3914,34 @@
"node": ">=0.10.0"
}
},
+ "node_modules/exsolve": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3964,6 +4217,23 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4657,12 +4927,20 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
- "dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.2.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
+ "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5322,6 +5600,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@@ -5332,6 +5616,29 @@
"node": ">=18"
}
},
+ "node_modules/nypm": {
+ "version": "0.6.9",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz",
+ "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.2",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.2.4"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "license": "MIT"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5455,6 +5762,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/ohash": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
+ "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5564,6 +5877,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5583,6 +5908,17 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5632,6 +5968,31 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prisma": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
+ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "6.19.3",
+ "@prisma/engines": "6.19.3"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5654,6 +6015,22 @@
"node": ">=6"
}
},
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5675,6 +6052,16 @@
],
"license": "MIT"
},
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -5703,6 +6090,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6345,6 +6745,15 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6546,7 +6955,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/package.json b/package.json
index d886a25..f3fc6de 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,17 @@
"lint": "eslint"
},
"dependencies": {
+ "@prisma/client": "^6.19.3",
+ "bcryptjs": "^3.0.3",
+ "jose": "^6.2.9",
"next": "16.3.0",
+ "prisma": "^6.19.3",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/prisma/migrations/20260819124025_init/migration.sql b/prisma/migrations/20260819124025_init/migration.sql
new file mode 100644
index 0000000..4f3d32b
--- /dev/null
+++ b/prisma/migrations/20260819124025_init/migration.sql
@@ -0,0 +1,41 @@
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "fullName" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "username" TEXT NOT NULL,
+ "password" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "Post" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ "authorId" TEXT NOT NULL,
+ CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "Comment" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "content" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "authorId" TEXT NOT NULL,
+ "postId" TEXT NOT NULL,
+ CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Post_authorId_slug_key" ON "Post"("authorId", "slug");
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..2a5a444
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..a0796a8
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,48 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id String @id @default(cuid())
+ fullName String
+ email String @unique
+ username String @unique
+ password String
+ createdAt DateTime @default(now())
+
+ posts Post[]
+ comments Comment[]
+}
+
+model Post {
+ id String @id @default(cuid())
+ title String
+ content String
+ slug String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ authorId String
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
+
+ comments Comment[]
+
+ @@unique([authorId, slug])
+}
+
+model Comment {
+ id String @id @default(cuid())
+ content String
+ createdAt DateTime @default(now())
+
+ authorId String
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
+
+ postId String
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
+}
\ No newline at end of file