Skip to content

Commit ca2ca0d

Browse files
committed
feat: implement blog management with admin interface and API endpoints
1 parent 02f6f2e commit ca2ca0d

File tree

11 files changed

+199
-3
lines changed

11 files changed

+199
-3
lines changed

app/admin/AdminBlogForm.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
import React, { useState } from "react";
3+
4+
export default function AdminBlogForm() {
5+
const [title, setTitle] = useState("");
6+
const [content, setContent] = useState("");
7+
8+
async function onSubmit(e: React.FormEvent) {
9+
e.preventDefault();
10+
11+
const res = await fetch("/api/admin/blogs", {
12+
method: "POST",
13+
headers: { "Content-Type": "application/json" },
14+
body: JSON.stringify({ title, content, published: true }),
15+
});
16+
if (!res.ok) alert(await res.text());
17+
else {
18+
setTitle("");
19+
setContent("");
20+
alert("Saved");
21+
}
22+
}
23+
24+
return (
25+
<form onSubmit={onSubmit} className="space-y-2">
26+
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Title" className="border p-2 w-full" />
27+
<textarea value={content} onChange={e=>setContent(e.target.value)} placeholder="Content" className="border p-2 w-full" />
28+
<button className="border px-3 py-2">Create</button>
29+
</form>
30+
);
31+
}

app/admin/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import AdminBlogForm from "./AdminBlogForm";
2+
3+
export default function AdminPage() {
4+
return (
5+
<main className="p-6 space-y-6">
6+
<h1 className="text-2xl font-bold">Admin</h1>
7+
<AdminBlogForm />
8+
</main>
9+
)
10+
}

app/api/admin/blogs/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { prisma } from "@/lib/prisma";
2+
3+
async function requireAdmin() {
4+
const isAdmin = true;
5+
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
6+
return null;
7+
}
8+
9+
export async function POST(req: Request) {
10+
const forbidden = await requireAdmin();
11+
if (forbidden) return forbidden;
12+
13+
const body = await req.json();
14+
const blog = await prisma.blog.create({
15+
data: {
16+
title: body.title,
17+
content: body.content,
18+
published: body.published ?? true,
19+
},
20+
});
21+
22+
return Response.json(blog);
23+
}

app/api/admin/route.ts

Whitespace-only changes.

app/api/blogs/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { prisma } from "@/lib/prisma";
2+
3+
export async function GET() {
4+
const blogs = await prisma.blog.findMany({
5+
where: { published: true },
6+
orderBy: { createdAt: "desc" },
7+
});
8+
9+
return Response.json(blogs);
10+
}

app/blogs/page.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
"use client";
22

3-
import { ArrowRight } from "lucide-react";
43
import { motion } from "framer-motion";
4+
import { useEffect, useState } from "react";
5+
6+
type BlogPost = {
7+
id: string;
8+
title: string;
9+
content: string | null;
10+
};
511

612
export default function Home() {
13+
const [posts, setPosts] = useState<BlogPost[]>([]);
14+
15+
useEffect(() => {
16+
let isMounted = true;
17+
18+
async function loadPosts() {
19+
const res = await fetch("/api/blogs", { cache: "no-store" });
20+
if (!res.ok || !isMounted) return;
21+
22+
const data = (await res.json()) as BlogPost[];
23+
setPosts(data);
24+
}
25+
26+
loadPosts();
27+
28+
return () => {
29+
isMounted = false;
30+
};
31+
}, []);
32+
733
return (
834
<div className="relative isolate flex min-h-screen items-center justify-center overflow-hidden">
935
<div className="pointer-events-none absolute inset-0 overflow-hidden bg-white dark:bg-gray-950">
@@ -35,10 +61,56 @@ export default function Home() {
3561
initial={{ opacity: 0, y: 24, filter: "blur(6px)" }}
3662
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
3763
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
38-
className="font-mono text-5xl tracking-tight text-gray-900 select-none sm:text-7xl md:text-8xl lg:text-9xl dark:text-white">
64+
className="font-mono text-4xl tracking-tight text-gray-900 select-none sm:text-6xl md:text-7xl lg:text-8xl dark:text-white">
3965

40-
sander.tf
66+
blogs
4167
</motion.h1>
68+
69+
<motion.p
70+
initial={{ opacity: 0, y: 16 }}
71+
animate={{ opacity: 1, y: 0 }}
72+
transition={{ delay: 0.15, duration: 0.6 }}
73+
className="mt-4 max-w-2xl text-sm text-slate-600 dark:text-slate-300 sm:text-base"
74+
>
75+
Thoughts, expiriments and logs.
76+
</motion.p>
77+
78+
{posts.length === 0 ? (
79+
<motion.div
80+
initial={{ opacity: 0, y: 12 }}
81+
animate={{ opacity: 1, y: 0 }}
82+
transition={{ delay: 0.25, duration: 0.5 }}
83+
className="mt-10 rounded-2xl border border-slate-200/700 bg-white/70 p-6 text-slate-700 shadow-sm backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/40 dark:text-slate-200"
84+
>
85+
No blogs posted.
86+
</motion.div>
87+
) : (
88+
<div className="mt-10 grid grid-cols-1 gap-4 sm:gap-5">
89+
{posts.map((p, i) => (
90+
<motion.article
91+
key={p.id}
92+
initial={{ opacity: 0, y: 18 }}
93+
animate={{ opacity: 1, y: 0 }}
94+
transition={{ delay: 0.08 * i, duration: 0.5 }}
95+
className="group rounded-2xl border border-slate-200/70 bg-white/70 p-5 shadow-sm backdrop-blur transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-700/70 dark:bg-slate-900/40"
96+
>
97+
<h2 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-white">
98+
{p.title}
99+
</h2>
100+
101+
<p className="mt-2 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-300">
102+
{p.content ?? "Geen inhoud toegevoegd."}
103+
</p>
104+
105+
<div className="mt-4">
106+
<button className="inline-flex items-center rounded-full border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800">
107+
Read more
108+
</button>
109+
</div>
110+
</motion.article>
111+
))}
112+
</div>
113+
)}
42114
</div>
43115
</div>
44116
);

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
services:
22
db:
33
image: postgres:16
4+
ports:
5+
- "5432:5432"
46
container_name: sander-tf-postgres
57
restart: unless-stopped
68
environment:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- CreateEnum
2+
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
3+
4+
-- AlterTable
5+
ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- CreateTable
2+
CREATE TABLE "Blogs" (
3+
"id" TEXT NOT NULL,
4+
"title" TEXT NOT NULL,
5+
"content" TEXT,
6+
"published" BOOLEAN NOT NULL DEFAULT true,
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
9+
CONSTRAINT "Blogs_pkey" PRIMARY KEY ("id")
10+
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the `Blogs` table. If the table is not empty, all the data it contains will be lost.
5+
6+
*/
7+
-- DropTable
8+
DROP TABLE "Blogs";
9+
10+
-- CreateTable
11+
CREATE TABLE "Blog" (
12+
"id" TEXT NOT NULL,
13+
"title" TEXT NOT NULL,
14+
"content" TEXT,
15+
"published" BOOLEAN NOT NULL DEFAULT true,
16+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
18+
CONSTRAINT "Blog_pkey" PRIMARY KEY ("id")
19+
);

0 commit comments

Comments
 (0)