801 lines
29 KiB
TypeScript
801 lines
29 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import Link from 'next/link';
|
||
|
||
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 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]);
|
||
|
||
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.');
|
||
}
|
||
};
|
||
|
||
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>
|
||
</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>
|
||
|
||
{/* 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={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>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Content (Markdown)</label>
|
||
<textarea
|
||
value={newPost.content}
|
||
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
|
||
rows={10}
|
||
required
|
||
/>
|
||
</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"
|
||
>
|
||
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">
|
||
{post.pinned && (
|
||
<span title="Angeheftet" className="absolute top-2 right-2 text-2xl">📌</span>
|
||
)}
|
||
<h3 className="text-xl font-semibold">{post.title}</h3>
|
||
<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>
|
||
);
|
||
}
|