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

801 lines
29 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';
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>
);
}