'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([]); const [currentPath, setCurrentPath] = useState([]); 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([]); const [pinned, setPinned] = useState(() => { if (typeof window !== 'undefined') { return JSON.parse(localStorage.getItem('pinnedPosts') || '[]'); } return []; }); const [pinFeedback, setPinFeedback] = useState(null); const [showChangePassword, setShowChangePassword] = useState(false); const [changePwOld, setChangePwOld] = useState(''); const [changePwNew, setChangePwNew] = useState(''); const [changePwConfirm, setChangePwConfirm] = useState(''); const [changePwFeedback, setChangePwFeedback] = useState(null); const [previewHtml, setPreviewHtml] = useState(''); const [editingPost, setEditingPost] = useState<{ slug: string, path: string } | null>(null); const [isDocker, setIsDocker] = useState(false); const router = useRouter(); const usernameRef = useRef(null); const passwordRef = useRef(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) => { 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 (
{pinFeedback && (
{pinFeedback}
)} {!isAuthenticated ? (

Admin Login

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" />
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" />
) : (

Admin Dashboard

{/* Docker warning above export button */} {isDocker && (
Warning: Docker is in use. Exporting will export the entire /app root directory (including all files and folders in the container's root).
)}
{/* Password Change Modal */} {showChangePassword && (

Passwort ändern

setChangePwOld(e.target.value)} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" required autoComplete="current-password" />
setChangePwNew(e.target.value)} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" required autoComplete="new-password" />
setChangePwConfirm(e.target.value)} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" required autoComplete="new-password" />
{changePwFeedback && (
{changePwFeedback}
)}
)} {/* Breadcrumbs with back button */}
{currentPath.length > 0 && ( )}
{breadcrumbs.map((crumb, index) => (
{index > 0 && /}
))}
{/* Show current folder path above post creation form */}
Current folder: {currentPath.join('/') || 'root'}
{/* Create Folder Form */}

Create New Folder

setNewFolderName(e.target.value)} placeholder="Folder name" className="flex-1 rounded-md border border-gray-300 px-3 py-2" required />
{/* Drag and Drop Zone */}

Drag and drop Markdown files here

Files will be uploaded to: {currentPath.join('/') || 'root'}

{/* Create Post Form */}

Create New Post

setNewPost({ ...newPost, title: e.target.value })} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" required />
setNewPost({ ...newPost, date: e.target.value })} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" required />
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" />