- Updated AboutButton to navigate to the about page using Next.js router. - Changed HeaderButtons and MobileNav to link directly to the about page. - Modified Home component to exclude the 'about' post from the posts list. - Added a helper function to strip YAML frontmatter from post summaries. - Enhanced API routes to handle reading and writing markdown files for posts.
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { format } from 'date-fns';
|
||
import React from 'react';
|
||
|
||
interface Post {
|
||
type: 'post';
|
||
slug: string;
|
||
title: string;
|
||
date: string;
|
||
tags: string[];
|
||
summary: string;
|
||
content: string;
|
||
createdAt: string;
|
||
pinned: boolean;
|
||
}
|
||
|
||
interface Folder {
|
||
type: 'folder';
|
||
name: string;
|
||
path: string;
|
||
children: (Folder | Post)[];
|
||
emoji?: string;
|
||
}
|
||
|
||
type Node = Folder | Post;
|
||
|
||
export default function Home() {
|
||
const [tree, setTree] = useState<Node[]>([]);
|
||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||
const [search, setSearch] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Get blog owner from env
|
||
const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous';
|
||
|
||
useEffect(() => {
|
||
loadTree();
|
||
|
||
// Set up Server-Sent Events for real-time updates (optional)
|
||
let eventSource: EventSource | null = null;
|
||
let fallbackInterval: NodeJS.Timeout | null = null;
|
||
|
||
const setupSSE = () => {
|
||
try {
|
||
eventSource = new EventSource('/api/posts/stream');
|
||
|
||
eventSource.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'update') {
|
||
loadTree();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error parsing SSE data:', error);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = (error) => {
|
||
console.error('SSE connection error:', error);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
// Fallback to minimal polling if SSE fails
|
||
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||
};
|
||
|
||
eventSource.onopen = () => {
|
||
console.log('SSE connection established');
|
||
// Clear any fallback interval if SSE is working
|
||
if (fallbackInterval) {
|
||
clearInterval(fallbackInterval);
|
||
fallbackInterval = null;
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('Failed to establish SSE connection:', error);
|
||
// Fallback to minimal polling if SSE is not supported
|
||
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||
}
|
||
};
|
||
|
||
setupSSE();
|
||
|
||
return () => {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
if (fallbackInterval) {
|
||
clearInterval(fallbackInterval);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const loadTree = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
const response = await fetch('/api/posts');
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
setTree(data);
|
||
setLastUpdate(new Date());
|
||
} catch (error) {
|
||
console.error('Fehler beim Laden der Beiträge:', error);
|
||
setError(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Manual refresh function
|
||
const handleRefresh = () => {
|
||
loadTree();
|
||
};
|
||
|
||
// Traverse the tree to the current path
|
||
const getCurrentNodes = (): Node[] => {
|
||
let nodes: Node[] = tree;
|
||
for (const segment of currentPath) {
|
||
const folder = nodes.find(
|
||
(n) => n.type === 'folder' && n.name === segment
|
||
) as Folder | undefined;
|
||
if (folder) {
|
||
nodes = folder.children;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return nodes;
|
||
};
|
||
|
||
const nodes = getCurrentNodes();
|
||
|
||
// Breadcrumbs
|
||
const breadcrumbs = [
|
||
{ name: 'Startseite', path: [] },
|
||
...currentPath.map((name, idx) => ({
|
||
name,
|
||
path: currentPath.slice(0, idx + 1),
|
||
})),
|
||
];
|
||
|
||
// Helper to strip YAML frontmatter
|
||
function stripFrontmatter(md: string): string {
|
||
if (!md) return '';
|
||
if (md.startsWith('---')) {
|
||
const end = md.indexOf('---', 3);
|
||
if (end !== -1) return md.slice(end + 3).replace(/^\s+/, '');
|
||
}
|
||
return md;
|
||
}
|
||
|
||
// Helper to recursively collect all posts from the tree
|
||
function collectPosts(nodes: Node[]): Post[] {
|
||
let posts: Post[] = [];
|
||
for (const node of nodes) {
|
||
if (node.type === 'post' && node.slug !== 'about') {
|
||
posts.push(node);
|
||
} else if (node.type === 'folder') {
|
||
posts = posts.concat(collectPosts(node.children));
|
||
}
|
||
}
|
||
return posts;
|
||
}
|
||
|
||
// Filter posts by search
|
||
function filterPosts(posts: Post[]): Post[] {
|
||
if (!search.trim()) return posts;
|
||
const q = search.trim().toLowerCase();
|
||
return posts.filter(post =>
|
||
post.title.toLowerCase().includes(q) ||
|
||
post.summary.toLowerCase().includes(q) ||
|
||
post.tags.some(tag => tag.toLowerCase().includes(q))
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
||
{/* Error display */}
|
||
{error && (
|
||
<div className="mb-4 p-4 bg-red-100 text-red-800 rounded">
|
||
<strong>Fehler:</strong> {error}
|
||
</div>
|
||
)}
|
||
{/* Mobile-first header section */}
|
||
<div className="mb-6 sm:mb-8 space-y-4 sm:space-y-0 sm:flex sm:flex-row sm:gap-4 sm:items-center sm:justify-between">
|
||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}'s Blog</h1>
|
||
<div className="w-full sm:w-auto flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
placeholder="Suche nach Titel, Tag oder Text..."
|
||
className="flex-1 sm:w-80 border border-gray-300 rounded-lg px-4 py-3 text-base focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={isLoading}
|
||
className="px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
title="Refresh content"
|
||
>
|
||
{isLoading ? (
|
||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
) : (
|
||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Last update indicator */}
|
||
{lastUpdate && (
|
||
<div className="text-xs text-gray-500 text-center sm:text-left mb-4">
|
||
Aktualisiert: {lastUpdate.toLocaleTimeString()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Search Results Section */}
|
||
{search.trim() && (
|
||
<div className="mb-8 sm:mb-10">
|
||
<h2 className="text-xl sm:text-2xl font-bold mb-4">Suchergebnisse</h2>
|
||
<div className="grid gap-4 sm:gap-8">
|
||
{(() => {
|
||
const posts = filterPosts(collectPosts(tree));
|
||
if (posts.length === 0) {
|
||
return <div className="text-gray-500 text-center py-8">Keine Beiträge gefunden.</div>;
|
||
}
|
||
return posts.map((post: any) => {
|
||
// Determine folder path from slug
|
||
let folderPath = '';
|
||
if (post.slug.includes('/')) {
|
||
folderPath = post.slug.split('/').slice(0, -1).join('/');
|
||
}
|
||
return (
|
||
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
|
||
{post.pinned && (
|
||
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
|
||
)}
|
||
<Link href={`/posts/${post.slug}`} className="block">
|
||
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
|
||
{folderPath && (
|
||
<div className="text-xs text-gray-400 mb-2">in <span className="font-mono">{folderPath}</span></div>
|
||
)}
|
||
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
|
||
{post.date ? (
|
||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||
) : (
|
||
<div className="flex flex-col items-center">
|
||
<div className="flex">
|
||
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
|
||
</div>
|
||
)}
|
||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||
</div>
|
||
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
|
||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||
{post.tags.map((tag: string) => {
|
||
const q = search.trim().toLowerCase();
|
||
const isMatch = q && tag.toLowerCase().includes(q);
|
||
return (
|
||
<span
|
||
key={tag}
|
||
className={`bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm ${isMatch ? 'bg-yellow-200 font-bold' : ''}`}
|
||
>
|
||
{tag}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</Link>
|
||
</article>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Normal Content (folders and posts) only if not searching */}
|
||
{!search.trim() && (
|
||
<>
|
||
{/* Mobile-friendly breadcrumbs */}
|
||
<nav className="mb-4 sm:mb-6 text-sm text-gray-600">
|
||
<div className="flex flex-wrap gap-1 sm:gap-2 items-center">
|
||
{breadcrumbs.map((bc, idx) => (
|
||
<span key={bc.name} className="flex items-center">
|
||
{idx > 0 && <span className="mx-1 text-gray-400">/</span>}
|
||
<button
|
||
className="hover:underline px-1 py-1 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
onClick={() => setCurrentPath(bc.path)}
|
||
disabled={idx === breadcrumbs.length - 1}
|
||
>
|
||
{bc.name}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="grid gap-4 sm:gap-8">
|
||
{/* Folders */}
|
||
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
|
||
<div
|
||
key={folder.path}
|
||
className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
|
||
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||
>
|
||
<span className="font-semibold text-base sm:text-lg">{folder.emoji || '📁'} {folder.name}</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Posts */}
|
||
{(() => {
|
||
const posts = nodes.filter((n) => n.type === 'post' && n.slug !== 'about');
|
||
const pinnedPosts = posts.filter((post: any) => post.pinned);
|
||
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
||
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
|
||
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
|
||
{post.pinned && (
|
||
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
|
||
)}
|
||
<Link href={`/posts/${post.slug}`} className="block">
|
||
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
|
||
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
|
||
{post.date ? (
|
||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||
) : (
|
||
<div className="flex flex-col items-center">
|
||
<div className="flex">
|
||
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
|
||
</div>
|
||
)}
|
||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||
</div>
|
||
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
|
||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||
{post.tags.map((tag: string) => (
|
||
<span
|
||
key={tag}
|
||
className="bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</Link>
|
||
</article>
|
||
));
|
||
})()}
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|