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 ? ( +
+