Files
markdownblog/src/app/admin/page.tsx
2025-07-27 17:13:21 +02:00

1490 lines
64 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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 `<pre><code class="hljs ${langClass}">${highlighted}</code></pre>`;
};
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[]>([]);
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 [rememberExportChoice, setRememberExportChoice] = useState<boolean>(false);
const [lastExportChoice, setLastExportChoice] = useState<string | null>(null);
const [emojiPickerOpen, setEmojiPickerOpen] = useState<string | null>(null);
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const monacoRef = useRef<any>(null);
const vimStatusRef = useRef(null);
const vimInstanceRef = useRef<any>(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<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(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 = `
<button id="close-btn" style="position: absolute; top: 10px; right: 15px; background: none; border: none; font-size: 24px; cursor: pointer; color: #666; font-weight: bold;">×</button>
<h2 style="margin: 0 0 20px 0; font-size: 1.5rem; font-weight: bold;">Export Method</h2>
<p style="margin: 0 0 20px 0; color: #666;">How are you hosting this application?</p>
<p style="margin: 0 0 20px 0; font-size: 0.9rem; color: #888; font-style: italic;">If you don't know your method of hosting, ask your Systems Administrator</p>
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 20px;">
<button id="docker-btn" style="padding: 10px 20px; background-color: #059669; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold;">Docker</button>
<button id="local-btn" style="padding: 10px 20px; background-color: #2563eb; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold;">Local</button>
</div>
<div style="margin-top: 20px; display: flex; justify-content: center;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" id="remember-choice" style="margin: 0;">
Remember my choice for next time
</label>
</div>
`;
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 (
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
{pinFeedback && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-4 sm:px-6 py-2 rounded shadow-lg z-50 text-sm sm:text-base">
{pinFeedback}
</div>
)}
{!isAuthenticated ? (
<div className="max-w-md mx-auto bg-white p-4 sm:p-8 rounded-lg shadow-md">
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm: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 px-3 py-2 text-base"
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 px-3 py-2 text-base"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 text-base font-medium"
>
Login
</button>
</form>
</div>
) : (
<div className="max-w-6xl mx-auto">
{/* Mobile-friendly header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<h1 className="text-2xl sm:text-3xl font-bold">Admin Dashboard</h1>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={handleLogout}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-red-600 to-pink-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-red-700 hover:to-pink-600 transition-all focus:outline-none focus:ring-2 focus:ring-red-400"
title="Logout"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span className="flex flex-col items-start">
<span>Abmelden</span>
<span className="text-xs font-normal text-red-100">Ausloggen</span>
</span>
</button>
<button
onClick={() => setShowChangePassword(true)}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-blue-600 to-cyan-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-blue-700 hover:to-cyan-600 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
title="Passwort ändern"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 11c1.104 0 2-.896 2-2s-.896-2-2-2-2 .896-2 2 .896 2 2 2zm6 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2v-5m12 0V9a6 6 0 10-12 0v4m12 0H6" />
</svg>
<span className="flex flex-col items-start">
<span>Passwort ändern</span>
<span className="text-xs font-normal text-blue-100">Passwort ändern</span>
</span>
</button>
</div>
<div className="flex flex-col sm:flex-row items-center gap-2">
<button
onClick={handleExportTarball}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-green-600 to-emerald-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-green-700 hover:to-emerald-600 transition-all focus:outline-none focus:ring-2 focus:ring-green-400"
title="Export Docker Posts"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h8M12 8v8" />
</svg>
<span className="flex flex-col items-start">
<span>Exportieren</span>
<span className="text-xs font-normal text-green-100">Alle exportieren</span>
</span>
</button>
<a
href={withBaseUrl('/admin/manage/rust-status')}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-teal-500 to-blue-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-teal-600 hover:to-blue-600 transition-all focus:outline-none focus:ring-2 focus:ring-teal-400"
title="View Rust Parser Dashboard"
style={{ minWidth: '160px' }}
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01" />
</svg>
<span className="flex flex-col items-start">
<span>Rust-Parser</span>
<span className="text-xs font-normal text-teal-100">Statistiken</span>
</span>
</a>
{/* VS Code Editor Button */}
<a
href={withBaseUrl('/admin/editor')}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-gray-700 to-blue-700 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-gray-800 hover:to-blue-800 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
title="Markdown Bearbeiter"
style={{ minWidth: '160px' }}
>
{/* VS Code SVG Icon */}
<svg width="24" height="24" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fillRule="evenodd" clipRule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style={{ mixBlendMode: 'overlay' }} opacity="0.25">
<path fillRule="evenodd" clipRule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.29775 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stopColor="white"/>
<stop offset="1" stopColor="white" stopOpacity="0"/>
</linearGradient>
</defs>
</svg>
<span className="flex flex-col items-start">
<span>Markdown Editor</span>
<span className="text-xs font-normal text-blue-100">Visual Studio Code</span>
</span>
</a>
{rememberExportChoice && lastExportChoice && (
<div className="flex items-center gap-1 text-xs text-gray-600 w-full sm:w-auto justify-center sm:justify-start">
<span>💾 {lastExportChoice === 'docker' ? 'Docker' : 'Local'}</span>
<button
onClick={clearExportChoice}
className="text-red-500 hover:text-red-700 p-1"
title="Clear remembered choice"
>
×
</button>
</div>
)}
</div>
</div>
</div>
{/* Password Change Modal */}
{showChangePassword && (
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50 p-4">
<div className="bg-white p-4 sm: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 p-2"
onClick={() => setShowChangePassword(false)}
title="Schließen"
>
×
</button>
<h2 className="text-lg sm: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 text-base"
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 text-base"
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 text-base"
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-3 px-4 rounded-md hover:bg-blue-700 text-base font-medium"
>
Passwort speichern
</button>
</form>
</div>
</div>
)}
{/* Mobile-friendly breadcrumbs */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
{currentPath.length > 0 && (
<button
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-sm sm:text-base"
title="Go back one level"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 sm:h-5 sm: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 flex-wrap items-center gap-1 sm:gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path.join('/')} className="flex items-center">
{index > 0 && <span className="mx-1 sm:mx-2 text-gray-500">/</span>}
<button
onClick={() => setCurrentPath(crumb.path)}
className={`px-2 py-1 rounded text-sm sm:text-base ${
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-xs sm:text-sm">
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
</div>
{/* Drag and Drop Zone */}
<div
className={`mb-6 sm:mb-8 p-4 sm: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-base sm:text-lg font-medium">Ziehe Markdown-Dateien hierher</p>
<p className="text-xs sm:text-sm">Dateien werden hochgeladen zu: {currentPath.join('/') || 'root'}</p>
</div>
</div>
{/* Create Folder Form */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base"
required
/>
<button
type="submit"
className="px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-base font-medium"
>
Ordner erstellen
</button>
</form>
</div>
{/* Create Post Form */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Erstelle neuen Beitrag</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 text-base"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Datum</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 text-base"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tags (komma-getrennt)</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 text-base"
placeholder="tag1, tag2, tag3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Zusammenfassung</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 text-base"
rows={2}
required
/>
</div>
<div className="flex items-center mb-2">
<input
type="checkbox"
id="vim-toggle"
checked={vimMode}
onChange={() => setVimMode(v => !v)}
className="mr-2"
/>
<label
htmlFor="vim-toggle"
className="text-sm font-bold"
style={{
fontFamily: "'JetBrains Mono', 'monospace', cursive",
fontStyle: 'italic',
fontWeight: 'bold',
}}
>
Vim Mode
</label>
<div ref={vimStatusRef} className="ml-4 text-xs text-gray-500 font-mono" />
</div>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="w-full sm:w-1/2">
<div style={{ height: '240px' }}>
<MonacoEditor
height="100%"
defaultLanguage="markdown"
value={newPost.content}
onChange={(value?: string) => setNewPost({ ...newPost, content: value || '' })}
options={{
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 14,
scrollBeyondLastLine: false,
theme: 'vs-light',
lineNumbers: 'on',
automaticLayout: true,
fontFamily: 'JetBrains Mono, monospace',
}}
onMount={(editor: any) => {
monacoRef.current = editor;
}}
/>
</div>
</div>
<div className="w-full sm:w-1/2">
<label className="block text-sm font-medium text-gray-700 mb-2">Vorschau</label>
<div className="p-3 sm:p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '240px' }}>
<div
className="prose prose-sm max-w-none"
style={{ fontFamily: 'inherit' }}
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
<style jsx global>{`
.prose code, .prose pre {
font-family: 'JetBrains Mono', monospace !important;
}
`}</style>
</div>
</div>
</div>
<button
type="submit"
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700"
>
{editingPost ? 'Speichern' : 'Beitrag erstellen'}
</button>
</form>
</div>
{/* Content List */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Inhalt:</h2>
<div className="space-y-4">
{/* Folders */}
{currentNodes
.filter((node): node is Folder => node.type === 'folder')
.map((folder) => {
const folderKey = getFolderKey(folder, currentPath);
const isPickerOpen = emojiPickerOpen === folderKey;
return (
<div
key={folder.path}
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50 relative"
onClick={() => setCurrentPath([...currentPath, folder.name])}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl sm:text-2xl">{folder.emoji || '📁'}</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
</div>
<button
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={e => {
e.preventDefault();
e.stopPropagation();
console.log('Opening emoji picker for folder:', folderKey);
setEmojiPickerOpen(folderKey);
setEmojiPickerAnchor(e.currentTarget);
}}
aria-label="Change folder emoji"
>
😀 Emoji
</button>
</div>
{/* Emoji Picker - positioned outside the button to avoid conflicts */}
{isPickerOpen && (
<div
ref={emojiPickerRef}
className="fixed z-50 bg-white rounded shadow-lg p-2 emoji-picker-container border"
style={{
minWidth: 300,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxHeight: '80vh',
overflow: 'auto'
}}
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Choose emoji for "{folder.name}"</span>
<button
className="text-gray-500 hover:text-gray-700"
onClick={closeEmojiPicker}
>
</button>
</div>
<EmojiPicker
onEmojiClick={(emojiData) => {
console.log('Emoji selected:', emojiData.emoji, 'for folder:', folder.path);
handleSetFolderEmoji(folder.path, emojiData.emoji);
}}
autoFocusSearch
width={300}
height={350}
theme={getEmojiPickerTheme()}
/>
</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-3 sm:p-4 relative flex flex-col gap-2">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<h3 className="text-lg sm: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-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
>
Edit
</button>
{post.pinned && (
<span title="Angeheftet" className="text-xl sm:text-2xl">📌</span>
)}
</div>
<p className="text-sm sm:text-base text-gray-600">{post.date}</p>
<p className="text-xs sm:text-sm text-gray-500">{post.summary}</p>
<div className="mt-2 flex flex-wrap gap-1 sm:gap-2">
{(post.tags || []).map((tag) => (
<span key={tag} className="bg-gray-100 px-2 py-1 rounded text-xs sm:text-sm">
{tag}
</span>
))}
</div>
</div>
));
})()}
</div>
</div>
{/* Mobile-friendly manage content section */}
<div className="w-full mt-8 sm: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-2xl sm: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-4 sm:p-6 rounded-lg shadow text-center">
<p className="text-gray-600 mb-2 text-sm sm:text-base">
Lösche Beiträge und Ordner, verwalte deine Inhaltsstruktur
</p>
{/* Folder navigation breadcrumbs */}
<div className="flex flex-wrap justify-center gap-1 sm:gap-2 mb-4">
<button
onClick={() => setManagePath([])}
className={`px-2 py-1 rounded text-sm sm:text-base ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
>
/
</button>
{managePath.map((name, idx) => (
<button
key={idx}
onClick={() => setManagePath(managePath.slice(0, idx + 1))}
className={`px-2 py-1 rounded text-sm sm:text-base ${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) => {
const folderKey = getFolderKey(folder, managePath);
const isPickerOpen = emojiPickerOpen === folderKey;
return (
<div
key={folder.path}
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center justify-between relative"
onClick={() => setManagePath([...managePath, folder.name])}
>
<div className="flex items-center gap-2">
<span className="text-xl sm:text-2xl">{folder.emoji || '📁'}</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
</div>
<button
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={e => {
e.preventDefault();
e.stopPropagation();
console.log('Opening emoji picker for folder:', folderKey);
setEmojiPickerOpen(folderKey);
setEmojiPickerAnchor(e.currentTarget);
}}
aria-label="Change folder emoji"
>
😀 Emoji
</button>
{/* Emoji Picker - positioned outside the button to avoid conflicts */}
{isPickerOpen && (
<div
ref={emojiPickerRef}
className="fixed z-50 bg-white rounded shadow-lg p-2 emoji-picker-container border"
style={{
minWidth: 300,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxHeight: '80vh',
overflow: 'auto'
}}
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Choose emoji for "{folder.name}"</span>
<button
className="text-gray-500 hover:text-gray-700"
onClick={closeEmojiPicker}
>
</button>
</div>
<EmojiPicker
onEmojiClick={(emojiData) => {
console.log('Emoji selected:', emojiData.emoji, 'for folder:', folder.path);
handleSetFolderEmoji(folder.path, emojiData.emoji);
}}
autoFocusSearch
width={300}
height={350}
theme={getEmojiPickerTheme()}
/>
</div>
)}
</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-lg sm:text-xl">📌</span>
)}
<div>
<div className="font-semibold text-sm sm:text-base">{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-xl sm:text-2xl focus:outline-none p-1 ${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={withBaseUrl('/admin/manage')} className="block mt-6 text-blue-600 hover:underline text-sm sm:text-base">Zur Inhaltsverwaltung</a>
</div>
)}
</div>
</div>
)}
</div>
);
}