Files
markdownblog/src/app/admin/page.tsx

980 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/*********************************************
* This is the main admin page for the blog.
*
* Written Jun 19 2025
**********************************************/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { marked } from 'marked';
import hljs from 'highlight.js';
import matter from 'gray-matter';
interface Post {
slug: string;
title: string;
date: string;
tags: string[];
summary: string;
content: string;
pinned: boolean;
}
interface Folder {
type: 'folder';
name: string;
path: string;
children: (Post | Folder)[];
}
interface Post {
type: 'post';
slug: string;
title: string;
date: string;
tags: string[];
summary: string;
content: string;
pinned: boolean;
}
type Node = Post | Folder;
export default function AdminPage() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [nodes, setNodes] = useState<Node[]>([]);
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [newPost, setNewPost] = useState({
title: '',
date: new Date().toISOString().split('T')[0],
tags: '',
summary: '',
content: '',
});
const [newFolderName, setNewFolderName] = useState('');
const [isDragging, setIsDragging] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; item: Node | null }>({
show: false,
item: null,
});
const [showManageContent, setShowManageContent] = useState(false);
const [managePath, setManagePath] = useState<string[]>([]);
const [pinned, setPinned] = useState<string[]>(() => {
if (typeof window !== 'undefined') {
return JSON.parse(localStorage.getItem('pinnedPosts') || '[]');
}
return [];
});
const [pinFeedback, setPinFeedback] = useState<string | null>(null);
const [showChangePassword, setShowChangePassword] = useState(false);
const [changePwOld, setChangePwOld] = useState('');
const [changePwNew, setChangePwNew] = useState('');
const [changePwConfirm, setChangePwConfirm] = useState('');
const [changePwFeedback, setChangePwFeedback] = useState<string | null>(null);
const [previewHtml, setPreviewHtml] = useState('');
const [editingPost, setEditingPost] = useState<{ slug: string, path: string } | null>(null);
const [isDocker, setIsDocker] = useState<boolean>(false);
const router = useRouter();
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Check if already authenticated
const auth = localStorage.getItem('adminAuth');
if (auth === 'true') {
setIsAuthenticated(true);
loadContent();
const interval = setInterval(loadContent, 500);
return () => clearInterval(interval);
}
}, []);
useEffect(() => {
localStorage.setItem('pinnedPosts', JSON.stringify(pinned));
}, [pinned]);
useEffect(() => {
marked.setOptions({
gfm: true,
breaks: true,
highlight: function(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
} else {
return hljs.highlightAuto(code).value;
}
}
} as any);
setPreviewHtml(marked.parse(newPost.content || '') as string);
}, [newPost.content]);
useEffect(() => {
// Check if docker is used
fetch('/api/admin/docker')
.then(res => res.json())
.then(data => setIsDocker(!!data.docker))
.catch(() => setIsDocker(false));
}, []);
const loadContent = async () => {
try {
const response = await fetch('/api/posts');
const data = await response.json();
setNodes(data);
} catch (error) {
console.error('Error loading content:', error);
}
};
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const user = (formData.get('username') as string) || usernameRef.current?.value || '';
const pass = (formData.get('password') as string) || passwordRef.current?.value || '';
if (user !== 'admin') {
alert('Ungültiger Benutzername');
return;
}
// Check password via API
const res = await fetch('/api/admin/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pass, mode: 'login' }),
});
const data = await res.json();
if (res.ok && data.success) {
setIsAuthenticated(true);
localStorage.setItem('adminAuth', 'true');
loadContent();
} else {
alert(data.error || 'Ungültiges Passwort');
}
};
const handleLogout = () => {
setIsAuthenticated(false);
localStorage.removeItem('adminAuth');
router.push('/');
};
const handleCreatePost = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/api/admin/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...newPost,
tags: newPost.tags.split(',').map(tag => tag.trim()),
path: currentPath.join('/'),
}),
});
if (response.ok) {
setNewPost({
title: '',
date: new Date().toISOString().split('T')[0],
tags: '',
summary: '',
content: '',
});
loadContent();
} else {
alert('Error creating post');
}
} catch (error) {
console.error('Error creating post:', error);
alert('Error creating post');
}
};
const handleCreateFolder = async (e: React.FormEvent) => {
e.preventDefault();
if (!newFolderName.trim()) {
alert('Please enter a folder name');
return;
}
try {
const response = await fetch('/api/admin/folders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newFolderName,
path: currentPath.join('/'),
}),
});
if (response.ok) {
setNewFolderName('');
loadContent();
} else {
alert('Error creating folder');
}
} catch (error) {
console.error('Error creating folder:', error);
alert('Error creating folder');
}
};
// Get current directory contents
const getCurrentNodes = (): Node[] => {
let currentNodes: Node[] = nodes;
for (const segment of currentPath) {
const folder = currentNodes.find(
(n) => n.type === 'folder' && n.name === segment
) as Folder | undefined;
if (folder) {
currentNodes = folder.children;
} else {
break;
}
}
return currentNodes;
};
const currentNodes = getCurrentNodes();
// Breadcrumbs
const breadcrumbs = [
{ name: 'Root', path: [] },
...currentPath.map((name, idx) => ({
name,
path: currentPath.slice(0, idx + 1),
})),
];
// Get nodes for manage content
const getManageNodes = (): Node[] => {
let currentNodes: Node[] = nodes;
for (const segment of managePath) {
const folder = currentNodes.find(
(n) => n.type === 'folder' && n.name === segment
) as Folder | undefined;
if (folder) {
currentNodes = folder.children;
} else {
break;
}
}
return currentNodes;
};
const manageNodes = getManageNodes();
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const markdownFiles = files.filter(file => file.name.endsWith('.md'));
if (markdownFiles.length === 0) {
alert('Please drop only Markdown files');
return;
}
for (const file of markdownFiles) {
try {
const content = await file.text();
const formData = new FormData();
formData.append('file', file);
formData.append('path', currentPath.join('/'));
const response = await fetch('/api/admin/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload ${file.name}`);
}
} catch (error) {
console.error(`Error uploading ${file.name}:`, error);
alert(`Error uploading ${file.name}`);
}
}
loadContent();
}, [currentPath]);
const handleDelete = async (node: Node) => {
setDeleteConfirm({ show: true, item: node });
};
const confirmDelete = async () => {
if (!deleteConfirm.item) return;
try {
const itemPath = currentPath.join('/');
const itemName = deleteConfirm.item.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item.slug;
console.log('Deleting item:', {
path: itemPath,
name: itemName,
type: deleteConfirm.item.type
});
const response = await fetch('/api/admin/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: itemPath,
name: itemName,
type: deleteConfirm.item.type,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to delete item');
}
console.log('Delete successful:', data);
await loadContent();
setDeleteConfirm({ show: false, item: null });
} catch (error) {
console.error('Error deleting item:', error);
alert(error instanceof Error ? error.message : 'Error deleting item');
}
};
const handlePin = async (slug: string) => {
setPinned((prev) => {
const newPinned = prev.includes(slug)
? prev.filter((s) => s !== slug)
: [slug, ...prev];
// Update pinned.json on the server
fetch('/api/admin/posts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: newPinned }),
})
.then((res) => {
if (!res.ok) {
res.json().then((data) => {
setPinFeedback(data.error || 'Fehler beim Aktualisieren der angehefteten Beiträge');
});
} else {
setPinFeedback(
newPinned.includes(slug)
? 'Beitrag angeheftet!'
: 'Beitrag gelöst!'
);
setTimeout(() => setPinFeedback(null), 2000);
}
})
.catch((err) => {
setPinFeedback('Fehler beim Aktualisieren der angehefteten Beiträge');
});
return newPinned;
});
};
// Password change handler
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangePwFeedback(null);
if (!changePwOld || !changePwNew || !changePwConfirm) {
setChangePwFeedback('Bitte alle Felder ausfüllen.');
return;
}
if (changePwNew !== changePwConfirm) {
setChangePwFeedback('Die neuen Passwörter stimmen nicht überein.');
return;
}
// Check old password
const res = await fetch('/api/admin/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: changePwOld, mode: 'login' }),
});
const data = await res.json();
if (!res.ok || !data.success) {
setChangePwFeedback('Altes Passwort ist falsch.');
return;
}
// Set new password
const res2 = await fetch('/api/admin/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: changePwNew }),
});
const data2 = await res2.json();
if (res2.ok && data2.success) {
setChangePwFeedback('Passwort erfolgreich geändert!');
setChangePwOld('');
setChangePwNew('');
setChangePwConfirm('');
setTimeout(() => setShowChangePassword(false), 1500);
} else {
setChangePwFeedback(data2.error || 'Fehler beim Ändern des Passworts.');
}
};
// Function to load a post's raw markdown
const loadPostRaw = async (slug: string, folderPath: string) => {
const params = new URLSearchParams({ slug, path: folderPath });
const res = await fetch(`/api/admin/posts/raw?${params.toString()}`);
if (!res.ok) {
alert('Error loading post');
return;
}
const text = await res.text();
const parsed = matter(text);
setNewPost({
title: parsed.data.title || '',
date: parsed.data.date || new Date().toISOString().split('T')[0],
tags: Array.isArray(parsed.data.tags) ? parsed.data.tags.join(', ') : (parsed.data.tags || ''),
summary: parsed.data.summary || '',
content: parsed.content || '',
});
setEditingPost({ slug, path: folderPath });
};
// Function to save edits
const handleEditPost = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingPost) return;
try {
// Always update date to today if changed
const today = new Date().toISOString().split('T')[0];
const newDate = newPost.date !== today ? today : newPost.date;
const newFrontmatter = matter.stringify(newPost.content, {
title: newPost.title,
date: newDate,
tags: newPost.tags.split(',').map(tag => tag.trim()),
summary: newPost.summary,
author: process.env.NEXT_PUBLIC_BLOG_OWNER + "'s" || 'Anonymous',
});
const response = await fetch('/api/admin/posts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: editingPost.slug,
path: editingPost.path,
content: newFrontmatter,
}),
});
if (response.ok) {
setEditingPost(null);
setNewPost({ title: '', date: today, tags: '', summary: '', content: '' });
loadContent();
} else {
alert('Error saving post');
}
} catch (error) {
alert('Error saving post');
}
};
function handleExportTarball() {
fetch('/api/admin/export')
.then(async (res) => {
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'markdownblog-export.tar.gz';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
})
.catch((err) => {
alert('Export failed: ' + err.message);
});
}
return (
<div className="min-h-screen bg-gray-100 p-8">
{pinFeedback && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded shadow-lg z-50">
{pinFeedback}
</div>
)}
{!isAuthenticated ? (
<div className="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-6">Admin Login</h1>
<form onSubmit={handleLogin} className="space-y-4" autoComplete="on">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Benutzername
</label>
<input
type="text"
id="username"
name="username"
ref={usernameRef}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Passwort
</label>
<input
type="password"
id="password"
name="password"
ref={passwordRef}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Login
</button>
</form>
</div>
) : (
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<div className="flex gap-2">
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Logout
</button>
<button
onClick={() => setShowChangePassword(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Passwort ändern
</button>
{/* Docker warning above export button */}
{isDocker && (
<div className="mb-2 px-4 py-2 bg-yellow-200 text-yellow-900 rounded border border-yellow-400 font-semibold text-sm text-center">
<span className="font-bold">Warning:</span> Docker is in use. Exporting will export the entire <span className="font-mono">/app</span> root directory (including all files and folders in the container's root).
</div>
)}
<button
onClick={() => {
if (isDocker) {
// Custom popup for Docker support message
const dockerSupportPopup = document.createElement('div');
dockerSupportPopup.innerHTML = 'Exporting from Docker is not supported yet.';
dockerSupportPopup.style.position = 'fixed';
dockerSupportPopup.style.top = '50%';
dockerSupportPopup.style.left = '50%';
dockerSupportPopup.style.transform = 'translate(-50%, -50%)';
dockerSupportPopup.style.backgroundColor = 'white';
dockerSupportPopup.style.padding = '20px';
dockerSupportPopup.style.borderRadius = '5px';
dockerSupportPopup.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.2)';
dockerSupportPopup.style.zIndex = '1000';
document.body.appendChild(dockerSupportPopup);
setTimeout(() => {
document.body.removeChild(dockerSupportPopup);
}, 3000);
} else {
handleExportTarball();
}
}}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Export Posts
</button>
</div>
</div>
{/* Password Change Modal */}
{showChangePassword && (
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full relative">
<button
className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 text-2xl"
onClick={() => setShowChangePassword(false)}
title="Schließen"
>
×
</button>
<h2 className="text-xl font-bold mb-4">Passwort ändern</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Altes Passwort</label>
<input
type="password"
value={changePwOld}
onChange={e => setChangePwOld(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Neues Passwort</label>
<input
type="password"
value={changePwNew}
onChange={e => setChangePwNew(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Neues Passwort bestätigen</label>
<input
type="password"
value={changePwConfirm}
onChange={e => setChangePwConfirm(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
autoComplete="new-password"
/>
</div>
{changePwFeedback && (
<div className="text-center text-sm text-red-600">{changePwFeedback}</div>
)}
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
>
Passwort speichern
</button>
</form>
</div>
</div>
)}
{/* Breadcrumbs with back button */}
<div className="flex items-center gap-4 mb-6">
{currentPath.length > 0 && (
<button
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
title="Go back one level"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
Back
</button>
)}
<div className="flex items-center gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path.join('/')} className="flex items-center">
{index > 0 && <span className="mx-2 text-gray-500">/</span>}
<button
onClick={() => setCurrentPath(crumb.path)}
className={`px-2 py-1 rounded ${
index === breadcrumbs.length - 1
? 'bg-blue-100 text-blue-800'
: 'hover:bg-gray-200'
}`}
>
{crumb.name}
</button>
</div>
))}
</div>
</div>
{/* Show current folder path above post creation form */}
<div className="mb-2 text-gray-500 text-sm">
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
</div>
{/* Create Folder Form */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex gap-4">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Folder name"
className="flex-1 rounded-md border border-gray-300 px-3 py-2"
required
/>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Create Folder
</button>
</form>
</div>
{/* Drag and Drop Zone */}
<div
className={`mb-8 p-8 border-2 border-dashed rounded-lg text-center ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="text-gray-600">
<p className="text-lg font-medium">Drag and drop Markdown files here</p>
<p className="text-sm">Files will be uploaded to: {currentPath.join('/') || 'root'}</p>
</div>
</div>
{/* Create Post Form */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-4">Create New Post</h2>
<form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={newPost.title}
onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date</label>
<input
type="date"
value={newPost.date}
onChange={(e) => setNewPost({ ...newPost, date: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tags (comma-separated)</label>
<input
type="text"
value={newPost.tags}
onChange={(e) => setNewPost({ ...newPost, tags: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
placeholder="tag1, tag2, tag3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Summary</label>
<textarea
value={newPost.summary}
onChange={(e) => setNewPost({ ...newPost, summary: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
rows={2}
required
/>
</div>
{/* Labels Row */}
<div className="flex flex-row gap-4">
<div className="w-full md:w-1/2 flex items-end h-10">
<label className="block text-sm font-medium text-gray-700">Content (Markdown)</label>
</div>
<div className="w-full md:w-1/2 flex items-end h-10">
<label className="block text-sm font-medium text-gray-700">Live Preview</label>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 mt-1">
{/* Markdown Editor */}
<div className="w-full md:w-1/2 flex flex-col">
<textarea
value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono" style={{ height: '320px' }}
rows={10}
required
/>
</div>
{/* Live Markdown Preview */}
<div className="w-full md:w-1/2 flex flex-col">
<div className="p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '320px' }}>
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div>
</div>
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
{editingPost ? 'Save Changes' : 'Create Post'}
</button>
</form>
</div>
{/* Content List */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">Content</h2>
<div className="space-y-4">
{/* Folders */}
{currentNodes
.filter((node): node is Folder => node.type === 'folder')
.map((folder) => (
<div
key={folder.path}
className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50"
onClick={() => setCurrentPath([...currentPath, folder.name])}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📁</span>
<span className="font-semibold text-lg">{folder.name}</span>
</div>
</div>
))}
{/* Posts: pinned first, then unpinned */}
{(() => {
const posts = currentNodes.filter((node): node is Post => node.type === 'post');
const pinnedPosts = posts.filter(post => post.pinned);
const unpinnedPosts = posts.filter(post => !post.pinned);
return [...pinnedPosts, ...unpinnedPosts].map((post) => (
<div key={post.slug} className="border rounded-lg p-4 relative flex flex-col gap-2">
<div className="flex items-center gap-4">
<h3 className="text-xl font-semibold flex-1">{post.title}</h3>
<button
onClick={() => {
// Split post.slug into folder and filename
const slugParts = post.slug.split('/');
const filename = slugParts[slugParts.length - 1];
const folder = slugParts.length > 1 ? slugParts.slice(0, -1).join('/') : currentPath.join('/');
loadPostRaw(filename, folder);
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-lg font-bold shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
>
✏️ Edit
</button>
{post.pinned && (
<span title="Angeheftet" className="text-2xl ml-2">📌</span>
)}
</div>
<p className="text-gray-600">{post.date}</p>
<p className="text-sm text-gray-500">{post.summary}</p>
<div className="mt-2 flex gap-2">
{(post.tags || []).map((tag) => (
<span key={tag} className="bg-gray-100 px-2 py-1 rounded text-sm">
{tag}
</span>
))}
</div>
</div>
));
})()}
</div>
</div>
<div className="w-full mt-12">
<button
className="w-full flex justify-center items-center bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer text-3xl focus:outline-none"
onClick={() => setShowManageContent((v) => !v)}
aria-label={showManageContent ? 'Hide Manage Content' : 'Show Manage Content'}
>
<span>{showManageContent ? '' : ''}</span>
</button>
{showManageContent && (
<div className="mt-4 bg-white p-6 rounded-lg shadow text-center">
<p className="text-gray-600 mb-2">
Delete posts and folders, manage your content structure
</p>
{/* Folder navigation breadcrumbs */}
<div className="flex flex-wrap justify-center gap-2 mb-4">
<button
onClick={() => setManagePath([])}
className={`px-2 py-1 rounded ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
>
Root
</button>
{managePath.map((name, idx) => (
<button
key={idx}
onClick={() => setManagePath(managePath.slice(0, idx + 1))}
className={`px-2 py-1 rounded ${idx === managePath.length - 1 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
>
{name}
</button>
))}
</div>
{/* Folders */}
<div className="space-y-2 mb-4">
{manageNodes.filter((n) => n.type === 'folder').map((folder: any) => (
<div
key={folder.path}
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
onClick={() => setManagePath([...managePath, folder.name])}
>
<span className="text-2xl">📁</span>
<span className="font-semibold text-lg">{folder.name}</span>
</div>
))}
</div>
{/* Posts (pinned first) */}
<div className="space-y-2">
{[...manageNodes.filter((n) => n.type === 'post' && pinned.includes(n.slug)),
...manageNodes.filter((n) => n.type === 'post' && !pinned.includes(n.slug))
].map((post: any) => (
<div
key={post.slug}
className={`border rounded-lg p-3 flex items-center gap-3 justify-between ${pinned.includes(post.slug) ? 'bg-yellow-100 border-yellow-400' : ''}`}
>
<div className="flex-1 text-left flex items-center gap-2">
{pinned.includes(post.slug) && (
<span title="Angeheftet" className="text-xl">📌</span>
)}
<div>
<div className="font-semibold">{post.title}</div>
<div className="text-xs text-gray-500">{post.date}</div>
<div className="text-xs text-gray-400">{post.summary}</div>
</div>
</div>
<button
onClick={() => handlePin(post.slug)}
className={`text-2xl focus:outline-none ${pinned.includes(post.slug) ? 'text-yellow-500' : 'text-gray-400 hover:text-yellow-500'}`}
title={pinned.includes(post.slug) ? 'Lösen' : 'Anheften'}
>
{pinned.includes(post.slug) ? '' : ''}
</button>
</div>
))}
</div>
<a href="/admin/manage" className="block mt-6 text-blue-600 hover:underline">Go to Content Manager</a>
</div>
)}
</div>
</div>
)}
</div>
);
}