very nice
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,4 +3,8 @@ node_modules
|
||||
electron/dist
|
||||
posts/admin.json
|
||||
posts/admin.json.tmp
|
||||
.vscode
|
||||
.vscode
|
||||
posts/pinned.json
|
||||
posts/Aquaworld/tag-1.md
|
||||
posts/pinned.json
|
||||
posts/pinned.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -16,6 +16,73 @@ interface Post {
|
||||
|
||||
export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
// 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);
|
||||
|
||||
// Prevent background scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (zoomImgSrc) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Reset offset and zoom when opening a new image
|
||||
useEffect(() => {
|
||||
if (zoomImgSrc) {
|
||||
setImgOffset({ x: 0, y: 0 });
|
||||
setZoomLevel(1.5);
|
||||
}
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Drag logic (mouse)
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setImgStart(imgOffset);
|
||||
};
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragging || !dragStart) return;
|
||||
setImgOffset({
|
||||
x: imgStart.x + (e.clientX - dragStart.x),
|
||||
y: imgStart.y + (e.clientY - dragStart.y),
|
||||
});
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
setDragStart(null);
|
||||
};
|
||||
|
||||
// Drag logic (touch)
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.touches[0].clientX, y: e.touches[0].clientY });
|
||||
setImgStart(imgOffset);
|
||||
};
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!dragging || !dragStart || e.touches.length !== 1) return;
|
||||
setImgOffset({
|
||||
x: imgStart.x + (e.touches[0].clientX - dragStart.x),
|
||||
y: imgStart.y + (e.touches[0].clientY - dragStart.y),
|
||||
});
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
setDragging(false);
|
||||
setDragStart(null);
|
||||
};
|
||||
|
||||
// Join the slug array to get the full path
|
||||
const slugPath = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
|
||||
@@ -493,6 +560,42 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
// Attach click handler to images in .prose
|
||||
useEffect(() => {
|
||||
if (!post) return;
|
||||
const prose = document.querySelectorAll('.prose');
|
||||
const handleImgClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
setZoomImgSrc((target as HTMLImageElement).src);
|
||||
setZoomLevel(1.5);
|
||||
}
|
||||
};
|
||||
prose.forEach((el) => el.addEventListener('click', handleImgClick));
|
||||
return () => {
|
||||
prose.forEach((el) => el.removeEventListener('click', handleImgClick));
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
// Keyboard ESC to close modal
|
||||
useEffect(() => {
|
||||
if (!zoomImgSrc) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setZoomImgSrc(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Zoom controls for desktop
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (window.innerWidth < 640) return; // skip on mobile
|
||||
e.preventDefault();
|
||||
setZoomLevel((z) => Math.max(0.2, Math.min(5, z + (e.deltaY < 0 ? 0.1 : -0.1))));
|
||||
};
|
||||
|
||||
// Pinch-to-zoom is native on mobile if image is in a scrollable container with touch gestures enabled
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`);
|
||||
@@ -509,6 +612,88 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
|
||||
return (
|
||||
<article className="min-h-screen">
|
||||
{/* Modal for zoomed image */}
|
||||
{zoomImgSrc && (
|
||||
<div
|
||||
ref={modalContainerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80 cursor-zoom-out select-none"
|
||||
onClick={() => setZoomImgSrc(null)}
|
||||
onWheel={handleWheel}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<div
|
||||
className="relative max-h-[100vh] max-w-[100vw] flex items-center justify-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pinch-zoom',
|
||||
}}
|
||||
>
|
||||
{/* Mobile X button */}
|
||||
<button
|
||||
className="absolute top-2 right-2 z-10 sm:hidden bg-white/90 rounded-full p-2 text-2xl font-bold shadow-lg"
|
||||
style={{ lineHeight: 1, width: 40, height: 40 }}
|
||||
onClick={() => setZoomImgSrc(null)}
|
||||
aria-label="Schließen"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
ref={modalImgRef}
|
||||
src={zoomImgSrc}
|
||||
alt="Zoomed"
|
||||
style={{
|
||||
maxHeight: '100vh',
|
||||
maxWidth: '100vw',
|
||||
transform: `translate(${imgOffset.x}px, ${imgOffset.y}px) scale(${zoomLevel})`,
|
||||
transition: dragging ? 'none' : 'transform 0.2s',
|
||||
cursor: dragging ? 'grabbing' : 'grab',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 2px 16px rgba(0,0,0,0.5)',
|
||||
background: 'white',
|
||||
touchAction: 'pinch-zoom',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
/>
|
||||
{/* Desktop zoom controls */}
|
||||
{window.innerWidth >= 640 && (
|
||||
<div className="absolute bottom-2 right-2 flex gap-2 bg-white/80 rounded p-1 shadow">
|
||||
<button
|
||||
className="px-2 py-1 text-lg font-bold"
|
||||
onClick={() => setZoomLevel(z => Math.min(5, z + 0.2))}
|
||||
aria-label="Zoom in"
|
||||
type="button"
|
||||
>+
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 text-lg font-bold"
|
||||
onClick={() => setZoomLevel(z => Math.max(0.2, z - 0.2))}
|
||||
aria-label="Zoom out"
|
||||
type="button"
|
||||
>-
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 text-lg font-bold"
|
||||
onClick={() => setZoomLevel(1.5)}
|
||||
aria-label="Reset zoom"
|
||||
type="button"
|
||||
>⟳
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile: Full width, no borders */}
|
||||
<div className="sm:hidden">
|
||||
{/* Mobile back button */}
|
||||
|
||||
Reference in New Issue
Block a user