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.
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s

This commit is contained in:
ZockerKatze
2025-06-24 07:23:34 +02:00
parent da5fbfa687
commit 7e2ada529d
7 changed files with 339 additions and 26 deletions

View File

@@ -3,6 +3,24 @@ const nextConfig = {
output: 'standalone', output: 'standalone',
reactStrictMode: true, reactStrictMode: true,
swcMinify: 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 module.exports = nextConfig

View File

@@ -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(() => { useEffect(() => {
async function fetchDetails() { async function fetchDetails() {
const details: Record<string, { created: string, items: number, size: number, error?: string }> = {}; const details: Record<string, { created: string, items: number, size: number, error?: string }> = {};
await Promise.all(currentNodes.filter(n => n.type === 'folder').map(async (folder: any) => { const folders = currentNodes.filter(n => n.type === 'folder');
details[folder.path] = await getFolderDetails(folder.path);
})); // Only fetch for folders that don't already have details
setFolderDetails(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(); 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(() => { useEffect(() => {
async function fetchSizes() { async function fetchSizes() {
const sizes: Record<string, { size: number | null, created: string | null }> = {}; const sizes: Record<string, { size: number | null, created: string | null }> = {};
await Promise.all(currentNodes.filter(n => n.type === 'post').map(async (post: any) => { const posts = currentNodes.filter(n => n.type === 'post');
sizes[post.slug] = await getPostSize(post.slug);
})); // Only fetch for posts that don't already have sizes
setPostSizes(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(); fetchSizes();
}, [currentNodes]); }, [currentPath]); // Only trigger when path changes, not on every currentNodes change
if (!isAuthenticated) { if (!isAuthenticated) {
return null; // Will redirect in useEffect return null; // Will redirect in useEffect
@@ -232,12 +246,23 @@ export default function ManagePage() {
Back to Admin Back to Admin
</Link> </Link>
</div> </div>
<button <div className="flex gap-2">
onClick={handleLogout} <button
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium" onClick={loadContent}
> className="px-4 py-3 sm:py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-base font-medium"
Logout title="Refresh content"
</button> >
<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>
<button
onClick={handleLogout}
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
>
Logout
</button>
</div>
</div> </div>
{/* Mobile-friendly breadcrumbs */} {/* Mobile-friendly breadcrumbs */}

View File

@@ -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<ReadableStreamDefaultController>();
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'
}
});
}

View File

@@ -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 });
}
}

View File

@@ -31,26 +31,90 @@ export default function Home() {
const [tree, setTree] = useState<Node[]>([]); const [tree, setTree] = useState<Node[]>([]);
const [currentPath, setCurrentPath] = useState<string[]>([]); const [currentPath, setCurrentPath] = useState<string[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// Get blog owner from env // Get blog owner from env
const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous'; const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous';
useEffect(() => { useEffect(() => {
loadTree(); 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 () => { const loadTree = async () => {
try { try {
setIsLoading(true);
const response = await fetch('/api/posts'); const response = await fetch('/api/posts');
const data = await response.json(); const data = await response.json();
setTree(data); setTree(data);
setLastUpdate(new Date());
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Beiträge:', 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 // Traverse the tree to the current path
const getCurrentNodes = (): Node[] => { const getCurrentNodes = (): Node[] => {
let nodes: Node[] = tree; let nodes: Node[] = tree;
@@ -107,17 +171,41 @@ export default function Home() {
{/* Mobile-first header section */} {/* 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"> <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> <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"> <div className="w-full sm:w-auto flex gap-2">
<input <input
type="text" type="text"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Suche nach Titel, Tag oder Text..." 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"
/> />
<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>
</div> </div>
{/* Last update indicator */}
{lastUpdate && (
<div className="text-xs text-gray-500 text-center sm:text-left mb-4">
Last updated: {lastUpdate.toLocaleTimeString()}
</div>
)}
{/* Search Results Section */} {/* Search Results Section */}
{search.trim() && ( {search.trim() && (
<div className="mb-8 sm:mb-10"> <div className="mb-8 sm:mb-10">

View File

@@ -91,11 +91,60 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
// Initial load // Initial load
loadPost(); loadPost();
// Set up polling for changes // Set up Server-Sent Events for real-time updates (optional)
const interval = setInterval(loadPost, 2000); 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 eventSource.onerror = (error) => {
return () => clearInterval(interval); 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]); }, [slugPath]);
// Enhanced anchor scrolling logic // Enhanced anchor scrolling logic

View File

@@ -235,6 +235,20 @@ function handleFileChange() {
if (onChangeCallback) { if (onChangeCallback) {
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() { export function stopWatching() {