Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
42 changes: 42 additions & 0 deletions SOLUTION.md
Original file line number Diff line number Diff line change
@@ -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.
119 changes: 119 additions & 0 deletions app/[username]/[slug]/CommentSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="border-t pt-8">
<h2 className="text-xl font-bold mb-4">Comments ({comments.length})</h2>

{currentUserId ? (
<div className="mb-8">
<textarea
className="w-full border rounded px-3 py-2 h-24"
placeholder="Write a comment..."
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<button
onClick={handleAdd}
disabled={loading}
className="mt-2 bg-black text-white px-4 py-2 rounded disabled:opacity-50"
>
{loading ? "Posting..." : "Post Comment"}
</button>
</div>
) : (
<p className="mb-8 text-sm">
<Link href="/login" className="underline">
Log in
</Link>{" "}
to leave a comment.
</p>
)}

<div className="space-y-4">
{comments.length === 0 && (
<p className="text-sm text-gray-600">No comments yet.</p>
)}

{comments.map((comment) => {
const canDelete =
currentUserId !== null &&
(comment.authorId === currentUserId ||
postAuthorId === currentUserId);

return (
<div key={comment.id} className="border rounded p-4">
<div className="flex justify-between items-start">
<p className="text-sm font-semibold">
{comment.author.fullName}{" "}
<span className="font-normal text-gray-600">
@{comment.author.username}
</span>
</p>
{canDelete && (
<button
onClick={() => handleDelete(comment.id)}
className="text-sm underline text-red-600"
>
Delete
</button>
)}
</div>
<p className="mt-2 whitespace-pre-wrap">{comment.content}</p>
<p className="text-xs text-gray-500 mt-2">
{new Date(comment.createdAt).toLocaleString()}
</p>
</div>
);
})}
</div>
</section>
);
}
48 changes: 48 additions & 0 deletions app/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-2">{post.title}</h1>
<p className="text-sm text-gray-600 mb-8">
by {post.author.fullName} · {new Date(post.createdAt).toLocaleDateString()}
</p>

<div className="whitespace-pre-wrap mb-12">{post.content}</div>

<CommentSection
postId={post.id}
postAuthorId={post.authorId}
comments={post.comments}
currentUserId={currentUser?.id ?? null}
/>
</div>
);
}
75 changes: 75 additions & 0 deletions app/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-2">{user.fullName}</h1>
<p className="text-gray-600 mb-8">@{user.username}</p>

{posts.length === 0 && <p>No posts yet.</p>}

<div className="space-y-6">
{posts.map((post) => (
<article key={post.id} className="border-b pb-4">
<Link
href={`/${user.username}/${post.slug}`}
className="text-xl font-semibold hover:underline"
>
{post.title}
</Link>
<p className="text-sm text-gray-600 mt-1">
{new Date(post.createdAt).toLocaleDateString()}
</p>
</article>
))}
</div>

{totalPages > 1 && (
<div className="flex gap-4 mt-8 items-center">
{page > 1 && (
<Link href={`/${username}?page=${page - 1}`} className="underline">
Previous
</Link>
)}
<span className="text-sm">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link href={`/${username}?page=${page + 1}`} className="underline">
Next
</Link>
)}
</div>
)}
</div>
);
}
20 changes: 20 additions & 0 deletions app/admin/DeletePostButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button onClick={handleDelete} className="underline text-red-600">
Delete
</button>
);
}
66 changes: 66 additions & 0 deletions app/admin/edit/[id]/EditPostForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Edit Post</h1>

{error && <p className="mb-4 text-red-600 text-sm">{error}</p>}

<div className="space-y-4">
<input
className="w-full border rounded px-3 py-2"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="w-full border rounded px-3 py-2 h-64"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<button
onClick={handleSubmit}
disabled={loading}
className="bg-black text-white px-6 py-2 rounded disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
Loading