Merge pull request 'image-and-database' (#6) from image-and-database into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/6
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,3 +4,7 @@ electron/dist
|
|||||||
posts/admin.json
|
posts/admin.json
|
||||||
posts/admin.json.tmp
|
posts/admin.json.tmp
|
||||||
.vscode
|
.vscode
|
||||||
|
posts/pinned.json
|
||||||
|
posts/Aquaworld/tag-1.md
|
||||||
|
posts/pinned.json
|
||||||
|
posts/pinned.json
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function POST(request: Request) {
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
// Return the current pinned.json object
|
// Return the current pinned.json object
|
||||||
try {
|
try {
|
||||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||||
console.log('Reading pinned.json from:', pinnedPath);
|
console.log('Reading pinned.json from:', pinnedPath);
|
||||||
let pinnedData = { pinned: [], folderEmojis: {} };
|
let pinnedData = { pinned: [], folderEmojis: {} };
|
||||||
if (fs.existsSync(pinnedPath)) {
|
if (fs.existsSync(pinnedPath)) {
|
||||||
@@ -73,7 +73,7 @@ export async function PATCH(request: Request) {
|
|||||||
if (!Array.isArray(pinned) || typeof folderEmojis !== 'object') {
|
if (!Array.isArray(pinned) || typeof folderEmojis !== 'object') {
|
||||||
return NextResponse.json({ error: 'Invalid pinned or folderEmojis data' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid pinned or folderEmojis data' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||||
console.log('Saving pinned.json to:', pinnedPath);
|
console.log('Saving pinned.json to:', pinnedPath);
|
||||||
console.log('Saving data:', { pinned, folderEmojis });
|
console.log('Saving data:', { pinned, folderEmojis });
|
||||||
fs.writeFileSync(pinnedPath, JSON.stringify({ pinned, folderEmojis }, null, 2), 'utf8');
|
fs.writeFileSync(pinnedPath, JSON.stringify({ pinned, folderEmojis }, null, 2), 'utf8');
|
||||||
|
|||||||
@@ -12,19 +12,6 @@ import { getPostsDirectory } from '@/lib/postsDirectory';
|
|||||||
|
|
||||||
const postsDirectory = getPostsDirectory();
|
const postsDirectory = getPostsDirectory();
|
||||||
|
|
||||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
|
||||||
let pinnedData: { pinned: string[]; folderEmojis: Record<string, string> } = { pinned: [], folderEmojis: {} };
|
|
||||||
if (fs.existsSync(pinnedPath)) {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(pinnedPath, 'utf8');
|
|
||||||
pinnedData = JSON.parse(raw);
|
|
||||||
if (!pinnedData.pinned) pinnedData.pinned = [];
|
|
||||||
if (!pinnedData.folderEmojis) pinnedData.folderEmojis = {};
|
|
||||||
} catch {
|
|
||||||
pinnedData = { pinned: [], folderEmojis: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get file creation date
|
// Function to get file creation date
|
||||||
function getFileCreationDate(filePath: string): Date {
|
function getFileCreationDate(filePath: string): Date {
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
@@ -62,7 +49,53 @@ marked.setOptions({
|
|||||||
renderer,
|
renderer,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getPostByPath(filePath: string, relPath: string) {
|
// Replace top-level pinnedData logic with a function
|
||||||
|
function getPinnedData() {
|
||||||
|
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||||
|
let pinnedData = { pinned: [], folderEmojis: {} };
|
||||||
|
if (fs.existsSync(pinnedPath)) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(pinnedPath, 'utf8');
|
||||||
|
pinnedData = JSON.parse(raw);
|
||||||
|
if (!pinnedData.pinned) pinnedData.pinned = [];
|
||||||
|
if (!pinnedData.folderEmojis) pinnedData.folderEmojis = {};
|
||||||
|
} catch {
|
||||||
|
pinnedData = { pinned: [], folderEmojis: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pinnedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update readPostsDir to accept pinnedData as an argument
|
||||||
|
async function readPostsDir(dir: string, relDir = '', pinnedData: { pinned: string[]; folderEmojis: Record<string, string> }): Promise<any[]> {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
const folders: any[] = [];
|
||||||
|
const posts: any[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
const relPath = relDir ? path.join(relDir, entry.name) : entry.name;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const children = await readPostsDir(fullPath, relPath, pinnedData);
|
||||||
|
// Debug log for emoji lookup
|
||||||
|
console.log('[FOLDER EMOJI DEBUG]', { relPath, allEmojis: pinnedData.folderEmojis, emoji: pinnedData.folderEmojis[relPath] });
|
||||||
|
const emoji = pinnedData.folderEmojis[relPath] || '📁';
|
||||||
|
folders.push({ type: 'folder', name: entry.name, path: relPath, emoji, children });
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
posts.push(await getPostByPath(fullPath, relPath, pinnedData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort posts by creation date (newest first)
|
||||||
|
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
// Folders first, then posts
|
||||||
|
return [...folders, ...posts];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update getPostByPath to accept pinnedData
|
||||||
|
async function getPostByPath(filePath: string, relPath: string, pinnedData: { pinned: string[]; folderEmojis: Record<string, string> }) {
|
||||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContents);
|
const { data, content } = matter(fileContents);
|
||||||
const createdAt = getFileCreationDate(filePath);
|
const createdAt = getFileCreationDate(filePath);
|
||||||
@@ -109,34 +142,11 @@ async function getPostByPath(filePath: string, relPath: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPostsDir(dir: string, relDir = ''): Promise<any[]> {
|
// Update GET handler to use fresh pinnedData
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
const folders: any[] = [];
|
|
||||||
const posts: any[] = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
const relPath = relDir ? path.join(relDir, entry.name) : entry.name;
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const children = await readPostsDir(fullPath, relPath);
|
|
||||||
const emoji = pinnedData.folderEmojis[relPath] || '📁';
|
|
||||||
folders.push({ type: 'folder', name: entry.name, path: relPath, emoji, children });
|
|
||||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
||||||
posts.push(await getPostByPath(fullPath, relPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort posts by creation date (newest first)
|
|
||||||
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
|
|
||||||
// Folders first, then posts
|
|
||||||
return [...folders, ...posts];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const tree = await readPostsDir(postsDirectory);
|
const pinnedData = getPinnedData();
|
||||||
|
const tree = await readPostsDir(postsDirectory, '', pinnedData);
|
||||||
return NextResponse.json(tree);
|
return NextResponse.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading posts:', error);
|
console.error('Error loading posts:', error);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -16,6 +16,73 @@ interface Post {
|
|||||||
|
|
||||||
export default function PostPage({ params }: { params: { slug: string[] } }) {
|
export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
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
|
// Join the slug array to get the full path
|
||||||
const slugPath = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
|
const slugPath = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
|
||||||
@@ -493,6 +560,42 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
|||||||
};
|
};
|
||||||
}, [post]);
|
}, [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 () => {
|
const loadPost = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`);
|
const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`);
|
||||||
@@ -509,6 +612,88 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-h-screen">
|
<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 */}
|
{/* Mobile: Full width, no borders */}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
{/* Mobile back button */}
|
{/* Mobile back button */}
|
||||||
|
|||||||
Reference in New Issue
Block a user