'use client'; export const dynamic = "force-dynamic"; /********************************************* * This is the main admin page for the blog. * * Written Jun 19 2025 * Rewritten fucking 15 times cause of the * fucking * typescript linter. * * If any Issues about "Window" (For Monaco) pop up. Its not my fucking fault * * Push later when on local Network. (//5jul25) ## Already done **********************************************/ 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'; import dynamicImport from 'next/dynamic'; import { Theme } from 'emoji-picker-react'; import '../highlight-github.css'; import { withBaseUrl } from '@/lib/baseUrl'; const MonacoEditor = dynamicImport(() => import('./MonacoEditor'), { ssr: false }); // Import monaco-vim only on client side let initVimMode: any = null; let VimMode: any = null; if (typeof window !== 'undefined') { const monacoVim = require('monaco-vim'); initVimMode = monacoVim.initVimMode; VimMode = monacoVim.VimMode; } import '@fontsource/jetbrains-mono'; 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)[]; emoji?: string; } interface Post { type: 'post'; slug: string; title: string; date: string; tags: string[]; summary: string; content: string; pinned: boolean; } type Node = Post | Folder; const EmojiPicker = dynamicImport(() => import('emoji-picker-react'), { ssr: false }); // Patch marked renderer to always add 'hljs' class to code blocks const renderer = new marked.Renderer(); renderer.code = function(code, infostring, escaped) { const lang = (infostring || '').match(/\S*/)?.[0]; const highlighted = lang && hljs.getLanguage(lang) ? hljs.highlight(code, { language: lang }).value : hljs.highlightAuto(code).value; const langClass = lang ? `language-${lang}` : ''; return `
${highlighted}
`; }; 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([]); 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 [rememberExportChoice, setRememberExportChoice] = useState(false); const [lastExportChoice, setLastExportChoice] = useState(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(null); const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); const emojiPickerRef = useRef(null); const router = useRouter(); const usernameRef = useRef(null); const passwordRef = useRef(null); const monacoRef = useRef(null); const vimStatusRef = useRef(null); const vimInstanceRef = useRef(null); const [vimMode, setVimMode] = useState(false); useEffect(() => { // Check if already authenticated const auth = localStorage.getItem('adminAuth'); if (auth === 'true') { setIsAuthenticated(true); loadContent(); } else { router.push('/admin'); } }, []); useEffect(() => { localStorage.setItem('pinnedPosts', JSON.stringify(pinned)); }, [pinned]); useEffect(() => { localStorage.setItem('rememberExportChoice', rememberExportChoice.toString()); }, [rememberExportChoice]); useEffect(() => { if (lastExportChoice) { localStorage.setItem('lastExportChoice', lastExportChoice); } else { localStorage.removeItem('lastExportChoice'); } }, [lastExportChoice]); useEffect(() => { marked.setOptions({ gfm: true, breaks: true, renderer, } as any); setPreviewHtml(marked.parse(newPost.content || '') as string); }, [newPost.content]); useEffect(() => { // Check if docker is used fetch(withBaseUrl('/api/admin/docker')) .then(res => res.json()) .then(data => setIsDocker(!!data.docker)) .catch(() => setIsDocker(false)); }, []); const loadContent = async () => { try { const response = await fetch(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl(`/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(withBaseUrl('/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() { if (typeof window === 'undefined') return; // Create popup modal const modal = document.createElement('div'); modal.style.position = 'fixed'; modal.style.top = '0'; modal.style.left = '0'; modal.style.width = '100%'; modal.style.height = '100%'; modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; modal.style.display = 'flex'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; modal.style.zIndex = '1000'; const modalContent = document.createElement('div'); modalContent.style.backgroundColor = 'white'; modalContent.style.padding = '30px'; modalContent.style.borderRadius = '8px'; modalContent.style.maxWidth = '500px'; modalContent.style.width = '90%'; modalContent.style.textAlign = 'center'; modalContent.style.position = 'relative'; modalContent.innerHTML = `

Export Method

How are you hosting this application?

If you don't know your method of hosting, ask your Systems Administrator

`; modal.appendChild(modalContent); document.body.appendChild(modal); // Set the checkbox state const rememberCheckbox = modal.querySelector('#remember-choice') as HTMLInputElement; if (rememberCheckbox) { rememberCheckbox.checked = rememberExportChoice; } // Add event listeners const dockerBtn = modal.querySelector('#docker-btn'); const localBtn = modal.querySelector('#local-btn'); const closeBtn = modal.querySelector('#close-btn'); const closeModal = () => { document.body.removeChild(modal); }; const handleExport = (choice: string) => { const shouldRemember = rememberCheckbox?.checked || false; setRememberExportChoice(shouldRemember); if (shouldRemember) { setLastExportChoice(choice); } closeModal(); if (choice === 'docker') { exportFromEndpoint(withBaseUrl('/api/admin/export')); } else if (choice === 'local') { exportFromEndpoint(withBaseUrl('/api/admin/exportlocal')); } }; dockerBtn?.addEventListener('click', () => { handleExport('docker'); }); localBtn?.addEventListener('click', () => { handleExport('local'); }); closeBtn?.addEventListener('click', closeModal); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { closeModal(); } }); } function exportFromEndpoint(endpoint: string) { if (typeof window === 'undefined') return; fetch(endpoint) .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 = endpoint.includes('local') ? 'local-export.tar.gz' : 'docker-export.tar.gz'; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); }) .catch((err) => { alert('Export failed: ' + err.message); }); } const clearExportChoice = () => { setRememberExportChoice(false); setLastExportChoice(null); }; // Hydrate pinned, rememberExportChoice, lastExportChoice from localStorage on client only useEffect(() => { if (typeof window !== 'undefined') { setPinned(JSON.parse(localStorage.getItem('pinnedPosts') || '[]')); setRememberExportChoice(localStorage.getItem('rememberExportChoice') === 'true'); setLastExportChoice(localStorage.getItem('lastExportChoice')); } }, []); // Simple and reliable emoji update handler const handleSetFolderEmoji = async (folderPath: string, emoji: string) => { try { console.log('Setting emoji for folder:', folderPath, 'to:', emoji); // Update local state immediately for instant feedback setNodes(prevNodes => { const updateFolderEmoji = (nodes: Node[]): Node[] => { return nodes.map(node => { if (node.type === 'folder') { if (node.path === folderPath) { return { ...node, emoji }; } else if (node.children) { return { ...node, children: updateFolderEmoji(node.children) }; } } return node; }); }; return updateFolderEmoji(prevNodes); }); // Close picker immediately setEmojiPickerOpen(null); setEmojiPickerAnchor(null); // Save to JSON file in background try { console.log('Fetching current pinned data...'); const pinnedRes = await fetch(withBaseUrl('/api/admin/posts'), { method: 'GET' }); if (!pinnedRes.ok) { throw new Error(`Failed to fetch pinned data: ${pinnedRes.status}`); } const pinnedData = await pinnedRes.json(); console.log('Current pinned data:', pinnedData); const folderEmojis = pinnedData.folderEmojis || {}; folderEmojis[folderPath] = emoji; console.log('Updated folderEmojis:', folderEmojis); console.log('Saving to pinned.json...'); const saveRes = await fetch(withBaseUrl('/api/admin/posts'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folderEmojis, pinned: pinnedData.pinned || [] }), }); if (!saveRes.ok) { throw new Error(`Failed to save emoji: ${saveRes.status}`); } console.log('Emoji saved to JSON successfully'); } catch (saveError) { console.error('Failed to save emoji to JSON:', saveError); // Don't show error to user since UI is already updated } } catch (e) { console.error('Error updating folder emoji:', e); alert(`Error updating folder emoji: ${e instanceof Error ? e.message : 'Unknown error'}`); setEmojiPickerOpen(null); setEmojiPickerAnchor(null); } }; // Add click outside handler to close emoji picker useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (emojiPickerOpen && emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Element)) { console.log('Closing emoji picker due to outside click'); setEmojiPickerOpen(null); setEmojiPickerAnchor(null); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && emojiPickerOpen) { console.log('Closing emoji picker due to escape key'); setEmojiPickerOpen(null); setEmojiPickerAnchor(null); } }; document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; }, [emojiPickerOpen]); // Add a unique key for each folder to prevent state conflicts const getFolderKey = (folder: Folder, currentPath: string[]) => { return `${currentPath.join('/')}/${folder.name}`; }; // Cleanup function to close picker const closeEmojiPicker = () => { setEmojiPickerOpen(null); setEmojiPickerAnchor(null); }; const getEmojiPickerTheme = (): Theme => { if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { return Theme.DARK; } return Theme.LIGHT; }; // Attach/detach Vim mode when vimMode changes useEffect(() => { if (vimMode && monacoRef.current && initVimMode) { // @ts-ignore vimInstanceRef.current = initVimMode(monacoRef.current, vimStatusRef.current); } else if (vimInstanceRef.current) { vimInstanceRef.current.dispose(); vimInstanceRef.current = null; } }, [vimMode, monacoRef.current]); 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 px-3 py-2 text-base" 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 px-3 py-2 text-base" required autoComplete="current-password" />
) : (
{/* Mobile-friendly header */}

Admin Dashboard

Rust-Parser Statistiken {/* VS Code Editor Button */} {/* VS Code SVG Icon */} Markdown Editor Visual Studio Code {rememberExportChoice && lastExportChoice && (
💾 {lastExportChoice === 'docker' ? 'Docker' : 'Local'}
)}
{/* 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 text-base" required autoComplete="current-password" />
setChangePwNew(e.target.value)} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base" required autoComplete="new-password" />
setChangePwConfirm(e.target.value)} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base" required autoComplete="new-password" />
{changePwFeedback && (
{changePwFeedback}
)}
)} {/* Mobile-friendly breadcrumbs */}
{currentPath.length > 0 && ( )}
{breadcrumbs.map((crumb, index) => (
{index > 0 && /}
))}
{/* Show current folder path above post creation form */}
Current folder: {currentPath.join('/') || 'root'}
{/* Drag and Drop Zone */}

Ziehe Markdown-Dateien hierher

Dateien werden hochgeladen zu: {currentPath.join('/') || 'root'}

{/* Create Folder Form */}

Create New Folder

setNewFolderName(e.target.value)} placeholder="Ordnername" className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base" required />
{/* Create Post Form */}

Erstelle neuen Beitrag

setNewPost({ ...newPost, title: e.target.value })} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base" required />
setNewPost({ ...newPost, date: e.target.value })} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base" required />
setNewPost({ ...newPost, tags: e.target.value })} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base" placeholder="tag1, tag2, tag3" />