From 7e2ada529dd1fdc6495b4d8b056b727cb994b01a Mon Sep 17 00:00:00 2001 From: ZockerKatze Date: Tue, 24 Jun 2025 07:23:34 +0200 Subject: [PATCH] Implement Server-Sent Events for real-time updates and enhance API route handling. Added loading state and last update indicator in the home page. Improved folder and post detail fetching logic in the admin page. Added webhook notification on file changes. --- next.config.js | 18 ++++++ src/app/admin/manage/page.tsx | 61 +++++++++++++------ src/app/api/posts/stream/route.ts | 57 ++++++++++++++++++ src/app/api/posts/webhook/route.ts | 62 +++++++++++++++++++ src/app/page.tsx | 96 ++++++++++++++++++++++++++++-- src/app/posts/[...slug]/page.tsx | 57 ++++++++++++++++-- src/lib/markdown.ts | 14 +++++ 7 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 src/app/api/posts/stream/route.ts create mode 100644 src/app/api/posts/webhook/route.ts diff --git a/next.config.js b/next.config.js index 557b862..d68bd34 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,24 @@ const nextConfig = { output: 'standalone', reactStrictMode: true, swcMinify: true, + // Exclude SSE route from static generation + experimental: { + serverComponentsExternalPackages: ['chokidar'] + }, + // Handle API routes that shouldn't be statically generated + async headers() { + return [ + { + source: '/api/posts/stream', + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + ], + }, + ]; + }, } module.exports = nextConfig \ No newline at end of file diff --git a/src/app/admin/manage/page.tsx b/src/app/admin/manage/page.tsx index 238fff7..47650b4 100644 --- a/src/app/admin/manage/page.tsx +++ b/src/app/admin/manage/page.tsx @@ -190,29 +190,43 @@ export default function ManagePage() { } }; - // Fetch folder details when currentNodes change + // Fetch folder details only when navigating to a new directory useEffect(() => { async function fetchDetails() { const details: Record = {}; - await Promise.all(currentNodes.filter(n => n.type === 'folder').map(async (folder: any) => { - details[folder.path] = await getFolderDetails(folder.path); - })); - setFolderDetails(details); + const folders = currentNodes.filter(n => n.type === 'folder'); + + // Only fetch for folders that don't already have details + const foldersToFetch = folders.filter((folder: any) => !folderDetails[folder.path]); + + if (foldersToFetch.length > 0) { + await Promise.all(foldersToFetch.map(async (folder: any) => { + details[folder.path] = await getFolderDetails(folder.path); + })); + setFolderDetails(prev => ({ ...prev, ...details })); + } } fetchDetails(); - }, [currentNodes]); + }, [currentPath]); // Only trigger when path changes, not on every currentNodes change - // Fetch post sizes and creation dates when currentNodes change + // Fetch post sizes only when navigating to a new directory useEffect(() => { async function fetchSizes() { const sizes: Record = {}; - await Promise.all(currentNodes.filter(n => n.type === 'post').map(async (post: any) => { - sizes[post.slug] = await getPostSize(post.slug); - })); - setPostSizes(sizes); + const posts = currentNodes.filter(n => n.type === 'post'); + + // Only fetch for posts that don't already have sizes + const postsToFetch = posts.filter((post: any) => postSizes[post.slug] === undefined); + + if (postsToFetch.length > 0) { + await Promise.all(postsToFetch.map(async (post: any) => { + sizes[post.slug] = await getPostSize(post.slug); + })); + setPostSizes(prev => ({ ...prev, ...sizes })); + } } fetchSizes(); - }, [currentNodes]); + }, [currentPath]); // Only trigger when path changes, not on every currentNodes change if (!isAuthenticated) { return null; // Will redirect in useEffect @@ -232,12 +246,23 @@ export default function ManagePage() { Back to Admin - +
+ + +
{/* Mobile-friendly breadcrumbs */} diff --git a/src/app/api/posts/stream/route.ts b/src/app/api/posts/stream/route.ts new file mode 100644 index 0000000..c0638d1 --- /dev/null +++ b/src/app/api/posts/stream/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { watchPosts, stopWatching } from '@/lib/markdown'; + +// Prevent static generation of this route +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Store connected clients +const clients = new Set(); + +export async function GET(request: NextRequest) { + const stream = new ReadableStream({ + start(controller) { + // Add this client to the set + clients.add(controller); + + // Send initial connection message + controller.enqueue(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`); + + // Set up file watcher if not already set up + if (clients.size === 1) { + watchPosts(() => { + // Notify all connected clients about the update + const message = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() }); + clients.forEach(client => { + try { + client.enqueue(`data: ${message}\n\n`); + } catch (error) { + // Remove disconnected clients + clients.delete(client); + } + }); + }); + } + + // Clean up when client disconnects + request.signal.addEventListener('abort', () => { + clients.delete(controller); + + // Stop watching if no clients are connected + if (clients.size === 0) { + stopWatching(); + } + }); + } + }); + + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + }); +} \ No newline at end of file diff --git a/src/app/api/posts/webhook/route.ts b/src/app/api/posts/webhook/route.ts new file mode 100644 index 0000000..79b5acb --- /dev/null +++ b/src/app/api/posts/webhook/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Prevent static generation of this route +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Store connected clients for webhook notifications +const webhookClients = new Set<{ id: string; controller: ReadableStreamDefaultController }>(); + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const clientId = searchParams.get('clientId') || Math.random().toString(36).substr(2, 9); + + const stream = new ReadableStream({ + start(controller) { + // Add this client to the set + webhookClients.add({ id: clientId, controller }); + + // Send initial connection message + controller.enqueue(`data: ${JSON.stringify({ type: 'connected', clientId, message: 'Webhook connection established' })}\n\n`); + + // Clean up when client disconnects + request.signal.addEventListener('abort', () => { + webhookClients.delete({ id: clientId, controller }); + }); + } + }); + + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + }); +} + +// Webhook endpoint that can be called when files change +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type = 'update', slug } = body; + + // Notify all connected clients + const message = JSON.stringify({ type, slug, timestamp: new Date().toISOString() }); + webhookClients.forEach(({ controller }) => { + try { + controller.enqueue(`data: ${message}\n\n`); + } catch (error) { + // Remove disconnected clients + webhookClients.delete({ id: '', controller }); + } + }); + + return NextResponse.json({ success: true, clientsNotified: webhookClients.size }); + } catch (error) { + console.error('Webhook error:', error); + return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index e30b504..4f32407 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,26 +31,90 @@ export default function Home() { const [tree, setTree] = useState([]); const [currentPath, setCurrentPath] = useState([]); const [search, setSearch] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); // Get blog owner from env const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous'; useEffect(() => { loadTree(); - const interval = setInterval(loadTree, 500); - return () => clearInterval(interval); + + // 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); const response = await fetch('/api/posts'); const data = await response.json(); setTree(data); + setLastUpdate(new Date()); } catch (error) { console.error('Fehler beim Laden der Beiträge:', error); + } finally { + setIsLoading(false); } }; + // Manual refresh function + const handleRefresh = () => { + loadTree(); + }; + // Traverse the tree to the current path const getCurrentNodes = (): Node[] => { let nodes: Node[] = tree; @@ -107,17 +171,41 @@ export default function Home() { {/* Mobile-first header section */}

{blogOwner}'s Blog

-
+
setSearch(e.target.value)} placeholder="Suche nach Titel, Tag oder Text..." - className="w-full 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" + 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" /> +
+ {/* Last update indicator */} + {lastUpdate && ( +
+ Last updated: {lastUpdate.toLocaleTimeString()} +
+ )} + {/* Search Results Section */} {search.trim() && (
diff --git a/src/app/posts/[...slug]/page.tsx b/src/app/posts/[...slug]/page.tsx index bc3aa0e..1bc16f0 100644 --- a/src/app/posts/[...slug]/page.tsx +++ b/src/app/posts/[...slug]/page.tsx @@ -91,11 +91,60 @@ export default function PostPage({ params }: { params: { slug: string[] } }) { // Initial load loadPost(); - // Set up polling for changes - const interval = setInterval(loadPost, 2000); + // 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' && data.slug === slugPath) { + loadPost(); + } + } catch (error) { + console.error('Error parsing SSE data:', error); + } + }; - // Cleanup - return () => clearInterval(interval); + eventSource.onerror = (error) => { + console.error('SSE connection error:', error); + if (eventSource) { + eventSource.close(); + eventSource = null; + } + // Fallback to minimal polling if SSE fails + fallbackInterval = setInterval(loadPost, 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(loadPost, 30000); // 30 seconds + } + }; + + setupSSE(); + + return () => { + if (eventSource) { + eventSource.close(); + } + if (fallbackInterval) { + clearInterval(fallbackInterval); + } + }; }, [slugPath]); // Enhanced anchor scrolling logic diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 18db88d..3827190 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -235,6 +235,20 @@ function handleFileChange() { if (onChangeCallback) { onChangeCallback(); } + + // Also notify via webhook if available + try { + fetch('/api/posts/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'update', timestamp: new Date().toISOString() }) + }).catch(error => { + // Webhook is optional, so we don't need to handle this as a critical error + console.debug('Webhook notification failed:', error); + }); + } catch (error) { + // Ignore webhook errors + } } export function stopWatching() {