Simple blog system (not quite done)

This commit is contained in:
2025-12-03 18:50:37 +01:00
parent 36ce2b9888
commit 0e128b5308
9 changed files with 224 additions and 100 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"title": "My Post",
"description": "This is a short description",
"author": "John Doe",
"date": "2025-12-03",
"readingTime": 1,
"tags": ["typescript", "nextjs"],
"image": "/images/my-post.jpg",
"category": "guides"
}
+1
View File
@@ -0,0 +1 @@
Hello world!
+45
View File
@@ -0,0 +1,45 @@
// app/api/blog/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { PostPreview } from "@/lib/posts";
const BLOG_DIR = path.join(process.cwd(), "blog");
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
// Read JSON metadata
const jsonPath = path.join(BLOG_DIR, `${slug}.json`);
if (!fs.existsSync(jsonPath)) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
const rawData = fs.readFileSync(jsonPath, "utf-8");
const data: Omit<PostPreview, "url"> = JSON.parse(rawData);
// Read Markdown content
const mdPath = path.join(BLOG_DIR, `${slug}.md`);
let content = "";
if (fs.existsSync(mdPath)) {
content = fs.readFileSync(mdPath, "utf-8");
}
return NextResponse.json({
meta: {
...data,
url: `/blog/${slug}`,
},
content,
});
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { PostPreview } from "@/lib/posts";
const BLOG_DIR = path.join(process.cwd(), "blog");
export function getAllBlogPreviews(): PostPreview[] {
const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith(".json"));
return files.map((file) => {
const filePath = path.join(BLOG_DIR, file);
const rawData = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(rawData);
// Generate the URL based on filename
const slug = file.replace(/\.json$/, "");
return {
...data,
url: `/blog/${slug}`,
} as PostPreview;
});
}
export function GET(req: NextRequest): NextResponse {
return NextResponse.json(getAllBlogPreviews());
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Sparkles } from "lucide-react";
import WebStuff from "@/components/Web";
import { useParams } from "next/navigation";
import { PostPreview } from "@/lib/posts";
export default function BlogPost() {
const [post, setPost] = useState<(PostPreview & { content: string }) | null>(
null
);
const [mounted, setMounted] = useState(false);
const { slug } = useParams();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
async function fetchPost() {
const res = await fetch(`/api/blog/${slug}`);
const data = await res.json();
setPost(data);
}
fetchPost();
}, [slug]);
if (!post) {
return (
<WebStuff>
<div className="min-h-screen flex items-center justify-center text-slate-400">
Loading post...
</div>
</WebStuff>
);
}
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
};
return (
<WebStuff>
Hero Section
<motion.section
className="relative z-10 px-6 py-16 lg:py-24 max-w-4xl mx-auto"
variants={containerVariants}
initial="hidden"
animate="visible"
>
<motion.div variants={itemVariants} className="mb-4">
<span className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500/10 border border-purple-500/20 rounded-full text-purple-400 text-sm">
<Sparkles className="w-4 h-4" />
{post.category}
</span>
</motion.div>
<motion.h1
variants={itemVariants}
className="text-5xl lg:text-6xl font-bold mb-6"
>
<span className="bg-linear-to-r from-white via-purple-200 to-blue-200 bg-clip-text text-transparent">
{post.title}
</span>
</motion.h1>
<motion.div
variants={itemVariants}
className="flex flex-wrap items-center gap-4 text-sm text-slate-400 mb-8"
>
<span>{post.author}</span>
<span>•</span>
<span>{new Date(post.date).toLocaleDateString()}</span>
{post.readingTime && (
<>
<span>•</span>
<span>{post.readingTime} min read</span>
</>
)}
{post.tags?.map((tag, idx) => (
<span
key={idx}
className="px-2 py-1 bg-slate-800/50 border border-slate-700 rounded-full"
>
{tag}
</span>
))}
</motion.div>
{/* Optional Featured Image */}
{post.image && (
<motion.div
variants={itemVariants}
className="mb-8 overflow-hidden rounded-xl"
>
<img
src={post.image}
alt={post.title}
className="w-full object-cover"
/>
</motion.div>
)}
{/* Post Content */}
<motion.article
variants={itemVariants}
className="prose prose-invert max-w-none text-slate-300"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</motion.section>
</WebStuff>
);
}
+7 -19
View File
@@ -2,39 +2,27 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import {
Github,
MessageSquare,
Sparkles,
ExternalLink,
Star,
GitFork,
} from "lucide-react";
import { Sparkles } from "lucide-react";
import WebStuff from "@/components/Web";
import projects from "@/lib/posts";
import formatNumber from "@/lib/formatNumber";
import { PostPreview } from "@/lib/posts";
export default function Blog() {
const [mounted, setMounted] = useState(false);
const [activeFilter, setActiveFilter] = useState("all");
const [orgStats, setOrgStats] = useState({
contributors: 0,
stars: 0,
forks: 0,
});
const [projects, setProjects] = useState<PostPreview[]>([]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
async function fetchStats() {
const res = await fetch(`/api/stats`);
async function fetchPosts() {
const res = await fetch(`/api/blog`);
setOrgStats(await res.json());
setProjects(await res.json());
}
fetchStats();
fetchPosts();
}, []);
const filters = [
+11
View File
@@ -0,0 +1,11 @@
export type PostPreview = {
title: string;
description: string;
author: string;
date: string;
readingTime: number;
tags: string[];
image: string;
url: string;
category: "news" | "guides" | "journal" | "research";
};
-81
View File
File diff suppressed because one or more lines are too long