Files
markdownblog/src/app/page.tsx
rattatwinko 525e4fdc35 Refactor navigation links to use Next.js routing and improve post handling
- 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.
2025-07-04 17:34:04 +02:00

374 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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}&apos;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>
);
}