'use client'; import React, { useState, useEffect, useRef } from 'react'; import { format } from 'date-fns'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; interface Post { slug: string; title: string; date: string; tags: string[]; summary: string; content: string; createdAt: string; author: string; } // Add a slugify function that matches Rust's slug::slugify function slugify(text: string): string { return text .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036F]/g, '') // Remove diacritics .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } export default function PostPage({ params }: { params: { slug: string[] } }) { const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const router = useRouter(); // Modal state for zoomed image const [zoomImgSrc, setZoomImgSrc] = useState(null); const [zoomLevel, setZoomLevel] = useState(1.5); // Start zoomed in const [imgOffset, setImgOffset] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null); const [imgStart, setImgStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const modalImgRef = useRef(null); const modalContainerRef = useRef(null); // Join the slug array to get the full path const slugPath = params.slug.join('/'); useEffect(() => { // Initial load loadPost(); // Set up Server-Sent Events for real-time updates 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') { loadPost(); } } 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(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]); const loadPost = async () => { try { setLoading(true); setError(null); const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setPost(data); } catch (error) { console.error('Fehler beim Laden des Beitrags:', error); setError(error instanceof Error ? error.message : 'Unknown error'); } finally { setLoading(false); } }; // Handle navigation to previous/next posts const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (zoomImgSrc) { setZoomImgSrc(null); } else { router.back(); } } }; useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [zoomImgSrc]); // Prevent background scroll when modal is open useEffect(() => { if (zoomImgSrc) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = 'unset'; } return () => { document.body.style.overflow = 'unset'; }; }, [zoomImgSrc]); // Image modal handlers const handleMouseDown = (e: React.MouseEvent) => { setDragging(true); setDragStart({ x: e.clientX, y: e.clientY }); setImgStart({ x: imgOffset.x, y: imgOffset.y }); }; const handleMouseMove = (e: React.MouseEvent) => { if (dragging && dragStart) { const deltaX = e.clientX - dragStart.x; const deltaY = e.clientY - dragStart.y; setImgOffset({ x: imgStart.x + deltaX, y: imgStart.y + deltaY, }); } }; const handleMouseUp = () => { setDragging(false); setDragStart(null); }; const handleTouchStart = (e: React.TouchEvent) => { const touch = e.touches[0]; setDragging(true); setDragStart({ x: touch.clientX, y: touch.clientY }); setImgStart({ x: imgOffset.x, y: imgOffset.y }); }; const handleTouchMove = (e: React.TouchEvent) => { if (dragging && dragStart) { const touch = e.touches[0]; const deltaX = touch.clientX - dragStart.x; const deltaY = touch.clientY - dragStart.y; setImgOffset({ x: imgStart.x + deltaX, y: imgStart.y + deltaY, }); } }; const handleTouchEnd = () => { setDragging(false); setDragStart(null); }; // Enhanced anchor scrolling logic useEffect(() => { const scrollToElement = (element: HTMLElement) => { const headerHeight = 80; // Approximate header height const elementTop = element.offsetTop - headerHeight; const container = document.documentElement; const containerHeight = container.clientHeight; const scrollTop = container.scrollTop; const elementHeight = element.offsetHeight; // Calculate the target scroll position let targetScrollTop = elementTop; // If element is taller than viewport, scroll to show the top if (elementHeight > containerHeight) { targetScrollTop = elementTop; } else { // Center the element in the viewport targetScrollTop = elementTop - (containerHeight - elementHeight) / 2; } // Ensure we don't scroll past the top targetScrollTop = Math.max(0, targetScrollTop); // Smooth scroll to the target position const startTime = performance.now(); const startScrollTop = scrollTop; const distance = targetScrollTop - startScrollTop; const duration = Math.min(Math.abs(distance) * 0.5, 1000); // Dynamic duration based on distance function performActualScroll(elementTop: number) { const currentTime = performance.now(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Easing function (ease-out) const easeOut = 1 - Math.pow(1 - progress, 3); const currentScrollTop = startScrollTop + distance * easeOut; container.scrollTop = currentScrollTop; if (progress < 1) { requestAnimationFrame(() => performActualScroll(elementTop)); } else { // Final adjustment to ensure we're exactly at the target container.scrollTop = targetScrollTop; } } if (duration > 0) { requestAnimationFrame(() => performActualScroll(elementTop)); } else { container.scrollTop = targetScrollTop; } }; const findAndScrollToElement = (id: string, retryCount: number = 0) => { const element = document.getElementById(id); if (element) { // Small delay to ensure the element is fully rendered setTimeout(() => scrollToElement(element), 50); } else if (retryCount < 10) { // Retry a few times in case the element hasn't been rendered yet setTimeout(() => findAndScrollToElement(id, retryCount + 1), 100); } }; // Handle hash in URL on page load const handleHashScroll = () => { if (typeof window !== 'undefined') { const hash = window.location.hash; if (hash) { const id = hash.substring(1); findAndScrollToElement(id); } } }; // Handle anchor clicks const handleAnchorClick = (event: MouseEvent) => { const target = event.target as HTMLElement; if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) { event.preventDefault(); const href = target.getAttribute('href'); if (href) { const id = href.substring(1); findAndScrollToElement(id); // Update URL without page reload if (typeof window !== 'undefined') { window.history.pushState(null, '', href); } } } }; // Set up event listeners document.addEventListener('click', handleAnchorClick); // Handle hash scroll on mount and when post changes if (post) { handleHashScroll(); } return () => { document.removeEventListener('click', handleAnchorClick); }; }, [post]); // Image click handler for modal useEffect(() => { const handleImgClick = (e: Event) => { const target = e.target as HTMLImageElement; if (target.tagName === 'IMG' && target.src) { e.preventDefault(); setZoomImgSrc(target.src); setImgOffset({ x: 0, y: 0 }); setZoomLevel(1.5); } }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && zoomImgSrc) { setZoomImgSrc(null); } }; document.addEventListener('click', handleImgClick); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('click', handleImgClick); document.removeEventListener('keydown', onKeyDown); }; }, [zoomImgSrc]); // Zoom handler for modal const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; setZoomLevel(prev => Math.max(0.5, Math.min(3, prev * delta))); }; // Loading state if (loading) { return (

Lade Beitrag...

); } // Error state if (error) { return (
⚠️

Fehler beim Laden

{error}

Zurück zur Startseite
); } if (!post) { return (

Beitrag nicht gefunden

Der angeforderte Beitrag konnte nicht gefunden werden.

Zurück zur Startseite
); } // Format date const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric', }); }; // Process tags const renderTags = (tags: string[]) => { if (!tags || tags.length === 0) return null; return (
{tags.map((tag, index) => ( router.push(`/?tag=${encodeURIComponent(tag)}`)} > #{tag} ))}
); }; return (
{/* Header */}
Zurück zur Startseite Zurück
{/* Main Content */}
{/* Post Header */}

{post.title}

{formatDate(post.date)} {post.author}
{post.summary && (

{post.summary}

)} {renderTags(post.tags)}
{/* Post Content */}
{/* Modal for zoomed image */} {zoomImgSrc && (
{ if (e.target === e.currentTarget) { setZoomImgSrc(null); } }} > Zoomed
)}
); }