980 lines
36 KiB
TypeScript
980 lines
36 KiB
TypeScript
'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>
|
||
);
|
||
}
|