Files
markdownblog/src/app/posts/[...slug]/page.tsx
2025-06-29 18:00:55 +02:00

539 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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