539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
'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<Post | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const router = useRouter();
|
||
|
||
// Modal state for zoomed image
|
||
const [zoomImgSrc, setZoomImgSrc] = useState<string | null>(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<HTMLImageElement>(null);
|
||
const modalContainerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||
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 (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
<p className="text-gray-600">Lade Beitrag...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Error state
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center max-w-md mx-auto p-6">
|
||
<div className="text-red-500 text-6xl mb-4">⚠️</div>
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Fehler beim Laden</h1>
|
||
<p className="text-gray-600 mb-6">{error}</p>
|
||
<Link
|
||
href="/"
|
||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||
</svg>
|
||
Zurück zur Startseite
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!post) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="text-red-500 text-6xl mb-4">❌</div>
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Beitrag nicht gefunden</h1>
|
||
<p className="text-gray-600 mb-6">Der angeforderte Beitrag konnte nicht gefunden werden.</p>
|
||
<Link
|
||
href="/"
|
||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||
</svg>
|
||
Zurück zur Startseite
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="flex flex-wrap gap-2 mb-6">
|
||
{tags.map((tag, index) => (
|
||
<span
|
||
key={index}
|
||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 transition-colors cursor-pointer"
|
||
onClick={() => router.push(`/?tag=${encodeURIComponent(tag)}`)}
|
||
>
|
||
#{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
{/* Header */}
|
||
<header className="bg-white shadow-sm border-b">
|
||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="flex items-center justify-between h-16">
|
||
<Link
|
||
href="/"
|
||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 sm:px-4 sm:py-2 sm:text-base"
|
||
>
|
||
<svg className="w-4 h-4 mr-2 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||
</svg>
|
||
<span className="hidden sm:inline">Zurück zur Startseite</span>
|
||
<span className="sm:hidden">Zurück</span>
|
||
</Link>
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
|
||
title="Zurück (ESC)"
|
||
>
|
||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Content */}
|
||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
<article className="bg-white rounded-lg shadow-sm border p-6 sm:p-8">
|
||
{/* Post Header */}
|
||
<header className="mb-8">
|
||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
|
||
{post.title}
|
||
</h1>
|
||
|
||
<div className="flex items-center text-gray-600 text-sm mb-4">
|
||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
{formatDate(post.date)}
|
||
<span className="mx-2">•</span>
|
||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||
</svg>
|
||
{post.author}
|
||
</div>
|
||
|
||
{post.summary && (
|
||
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||
{post.summary}
|
||
</p>
|
||
)}
|
||
|
||
{renderTags(post.tags)}
|
||
</header>
|
||
|
||
{/* Post Content */}
|
||
<div
|
||
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 prose-code:text-gray-800 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-900 prose-pre:text-gray-100"
|
||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||
/>
|
||
</article>
|
||
</main>
|
||
|
||
{/* Modal for zoomed image */}
|
||
{zoomImgSrc && (
|
||
<div
|
||
ref={modalContainerRef}
|
||
className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchMove={handleTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
onWheel={handleWheel}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setZoomImgSrc(null);
|
||
}
|
||
}}
|
||
>
|
||
<img
|
||
ref={modalImgRef}
|
||
src={zoomImgSrc}
|
||
alt="Zoomed"
|
||
className="max-w-none select-none"
|
||
style={{
|
||
transform: `scale(${zoomLevel}) translate(${imgOffset.x / zoomLevel}px, ${imgOffset.y / zoomLevel}px)`,
|
||
transition: dragging ? 'none' : 'transform 0.1s ease-out',
|
||
}}
|
||
draggable={false}
|
||
/>
|
||
<button
|
||
onClick={() => setZoomImgSrc(null)}
|
||
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
|
||
title="Close (ESC)"
|
||
>
|
||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|