1490 lines
64 KiB
TypeScript
1490 lines
64 KiB
TypeScript
'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>
|
||
);
|
||
}
|