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() {