diff --git a/posts/about.md b/posts/about.md new file mode 100644 index 0000000..279ab7a --- /dev/null +++ b/posts/about.md @@ -0,0 +1,30 @@ +--- +title: About Me +date: 2025-07-04 +tags: [about, profile] +author: rattatwinko +summary: This is the about page +--- +# About Me + +Hi! I'm Rattatwinko, a passionate developer who loves building self-hosted tools and beautiful web experiences. + +![](assets/peta.png) + +## Skills +- TypeScript, JavaScript +- Rust +- React, Next.js +- Tailwind CSS +- Docker + +## Experience +- Indie developer, 2020โ€“present +- Open source contributor + +## Projects +- **MarkdownBlog**: The site you're reading now! A fast, modern, and hackable markdown blog platform. + +## Contact +- [GitHub](https://github.com/rattatwinko) +- [Email](mailto:me@example.com) \ No newline at end of file diff --git a/posts/assets/peta.png b/posts/assets/peta.png new file mode 100644 index 0000000..5716be0 Binary files /dev/null and b/posts/assets/peta.png differ diff --git a/src/app/AboutButton.tsx b/src/app/AboutButton.tsx index b313436..760f1d6 100644 --- a/src/app/AboutButton.tsx +++ b/src/app/AboutButton.tsx @@ -1,5 +1,6 @@ 'use client'; import BadgeButton from './BadgeButton'; +import { useRouter } from 'next/navigation'; const InfoIcon = (
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..2a1b5ac --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +interface Post { + slug: string; + title: string; + date: string; + tags: string[]; + summary?: string; + content: string; + createdAt: string; + author: string; +} + +export default function AboutPage() { + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadAbout = async () => { + try { + setLoading(true); + setError(null); + const response = await fetch("/api/posts/about"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + setPost(data); + } catch (error) { + setError(error instanceof Error ? error.message : "Unknown error"); + } finally { + setLoading(false); + } + }; + loadAbout(); + }, []); + + if (loading) { + return ( +
+
+
+

Lade About...

+
+
+ ); + } + + if (error) { + return ( +
+
+
โš ๏ธ
+

Fehler beim Laden

+

{error}

+
+
+ ); + } + + if (!post) { + return ( +
+
+
โŒ
+

About nicht gefunden

+

Die About-Seite konnte nicht gefunden werden.

+
+
+ ); + } + + return ( +
+
+
+
+

+ {post.title || "About"} +

+ {post.summary && ( +

+ {post.summary} +

+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/admin/editor/page.tsx b/src/app/admin/editor/page.tsx new file mode 100644 index 0000000..a5051b6 --- /dev/null +++ b/src/app/admin/editor/page.tsx @@ -0,0 +1,370 @@ +"use client"; +import React, { useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import "@fontsource/jetbrains-mono"; +import { marked } from "marked"; + +const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); + +// File/folder types from API +interface FileNode { + type: "post"; + slug: string; + title: string; + date: string; + tags: string[]; + summary: string; + content: string; + createdAt: string; + pinned: boolean; +} +interface FolderNode { + type: "folder"; + name: string; + path: string; + emoji: string; + children: (FileNode | FolderNode)[]; +} +type Node = FileNode | FolderNode; + +// Helper to strip YAML frontmatter +function stripFrontmatter(md: string): string { + if (!md) return ''; + if (md.startsWith('---')) { + const end = md.indexOf('---', 3); + if (end !== -1) return md.slice(end + 3).replace(/^\s+/, ''); + } + return md; +} + +function FileTree({ nodes, onSelect, selectedSlug, level = 0 }: { + nodes: Node[]; + onSelect: (slug: string) => void; + selectedSlug: string | null; + level?: number; +}) { + const [openFolders, setOpenFolders] = useState>({}); + return ( +
    + {nodes.map((node) => { + if (node.type === "folder") { + const isOpen = openFolders[node.path] ?? true; + return ( +
  • + + {isOpen && ( + + )} +
  • + ); + } else { + return ( +
  • + +
  • + ); + } + })} +
+ ); +} + +export default function EditorPage() { + // State + const [tree, setTree] = useState([]); + const [selectedSlug, setSelectedSlug] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [fileTitle, setFileTitle] = useState(""); + const [vimMode, setVimMode] = useState(false); + const [previewHtml, setPreviewHtml] = useState(""); + const [split, setSplit] = useState(50); // percent + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [browserOpen, setBrowserOpen] = useState(true); + const editorRef = useRef(null); + const monacoVimRef = useRef(null); + + // Fetch file tree + useEffect(() => { + fetch("/api/posts") + .then(r => r.json()) + .then(setTree); + }, []); + + // Load file content when selected + useEffect(() => { + if (!selectedSlug) return; + setLoading(true); + fetch(`/api/posts/${encodeURIComponent(selectedSlug)}`) + .then(r => r.json()) + .then(data => { + setFileContent(stripFrontmatter(data.raw || data.content || "")); + setFileTitle(data.title || data.slug || ""); + setLoading(false); + }); + }, [selectedSlug]); + + // Save file + async function handleSave() { + if (!selectedSlug) return; + setSaving(true); + await fetch(`/api/posts/${encodeURIComponent(selectedSlug)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ markdown: fileContent }) + }); + setSaving(false); + } + + // Live preview (JS markdown, not Rust) + useEffect(() => { + if (!fileContent) { setPreviewHtml(""); return; } + const html = typeof marked.parse === 'function' ? marked.parse(stripFrontmatter(fileContent)) : ''; + if (typeof html === 'string') setPreviewHtml(html); + else if (html instanceof Promise) html.then(setPreviewHtml); + else setPreviewHtml(''); + }, [fileContent]); + + // Monaco Vim integration + async function handleEditorDidMount(editor: any, monaco: any) { + editorRef.current = editor; + if (vimMode) { + const { initVimMode } = await import("monaco-vim"); + if (monacoVimRef.current) monacoVimRef.current.dispose(); + monacoVimRef.current = initVimMode(editor, document.getElementById("vim-status")); + } + } + useEffect(() => { + if (!editorRef.current) return; + let disposed = false; + async function setupVim() { + if (monacoVimRef.current) monacoVimRef.current.dispose(); + if (vimMode) { + const { initVimMode } = await import("monaco-vim"); + if (!disposed) { + monacoVimRef.current = initVimMode(editorRef.current, document.getElementById("vim-status")); + } + } + } + setupVim(); + return () => { disposed = true; }; + }, [vimMode]); + + // Split drag logic + const dragRef = useRef(false); + function onDrag(e: React.MouseEvent) { + if (!dragRef.current) return; + const percent = (e.clientX / window.innerWidth) * 100; + setSplit(Math.max(20, Math.min(80, percent))); + } + function onDragStart() { dragRef.current = true; document.body.style.cursor = "col-resize"; } + function onDragEnd() { dragRef.current = false; document.body.style.cursor = ""; } + useEffect(() => { + function onMove(e: MouseEvent) { onDrag(e as any); } + function onUp() { onDragEnd(); } + if (dragRef.current) { + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; + } + }, [dragRef.current]); + + // Only render MonacoEditor if the editor pane is visible and has width + const showEditor = browserOpen ? true : split > 5; + + return ( +
+ {/* Header */} +
+
+ {/* VS Code SVG Icon (smaller) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markdown Editor +
+
+ + +
+
+ {/* Split Layout */} +
+ {/* Left: File browser + Editor */} +
+ {/* File Browser Collapsible Toggle */} + + {browserOpen && ( +
+ {tree.length === 0 ? ( +
No files found.
+ ) : ( + + )} +
+ )} + {/* Monaco Editor */} +
+
+ {showEditor && ( + + setFileContent(v ?? "")} + onMount={handleEditorDidMount} + /> + + )} +
+
+
+
+ {/* Draggable Splitter */} +
+ {/* Right: Live Preview */} +
+
+
+

+ {fileTitle} +

+
+
+
+
+
+
+ ); +} + +// ErrorBoundary component for Monaco +class MonacoErrorBoundary extends React.Component<{children: React.ReactNode}, {error: Error | null}> { + constructor(props: any) { + super(props); + this.state = { error: null }; + } + static getDerivedStateFromError(error: Error) { + return { error }; + } + componentDidCatch(error: Error, info: any) { + // Optionally log error + } + render() { + if (this.state.error) { + return
Editor error: {this.state.error.message}
; + } + return this.props.children; + } +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0a23a80..1e0f452 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -896,6 +896,60 @@ export default function AdminPage() { Statistiken + {/* VS Code Editor Button */} + + {/* VS Code SVG Icon */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Editor + VS Code + + {rememberExportChoice && lastExportChoice && (
๐Ÿ’พ {lastExportChoice === 'docker' ? 'Docker' : 'Local'} diff --git a/src/app/api/posts/[slug]/route.ts b/src/app/api/posts/[slug]/route.ts index bb1d1c1..8fbb666 100644 --- a/src/app/api/posts/[slug]/route.ts +++ b/src/app/api/posts/[slug]/route.ts @@ -19,6 +19,13 @@ export async function GET( ); if (rustResult.status === 0 && rustResult.stdout) { const post = JSON.parse(rustResult.stdout); + const fs = require('fs'); + const filePath = path.join(postsDirectory, slugPath + '.md'); + let raw = ''; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch {} + post.raw = raw; post.createdAt = post.created_at; delete post.created_at; return NextResponse.json(post); @@ -33,4 +40,20 @@ export async function GET( ); } } + +export async function POST(request: Request, { params }: { params: { slug: string[] | string } }) { + try { + const { markdown } = await request.json(); + if (typeof markdown !== 'string') { + return NextResponse.json({ error: 'Invalid markdown' }, { status: 400 }); + } + const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug]; + const slugPath = slugArr.join('/'); + const filePath = path.join(postsDirectory, slugPath + '.md'); + require('fs').writeFileSync(filePath, markdown, 'utf8'); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: 'Error saving file', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/posts/preview/route.ts b/src/app/api/posts/preview/route.ts new file mode 100644 index 0000000..53c0eb8 --- /dev/null +++ b/src/app/api/posts/preview/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { spawnSync } from 'child_process'; +import path from 'path'; + +export async function POST(request: Request) { + try { + const { markdown } = await request.json(); + if (typeof markdown !== 'string') { + return NextResponse.json({ error: 'Invalid markdown' }, { status: 400 }); + } + // Call Rust backend with 'render' command, pass markdown via stdin + const rustPath = path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'); + const rustResult = spawnSync(rustPath, ['render'], { + input: markdown, + encoding: 'utf-8', + }); + if (rustResult.status === 0 && rustResult.stdout) { + return NextResponse.json({ html: rustResult.stdout }); + } else { + const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error'; + return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 }); + } + } catch (error) { + return NextResponse.json({ error: 'Error rendering markdown', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index af5fd5e..123c9f8 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -73,6 +73,10 @@ async function readPostsDir(dir: string, relDir = '', pinnedData: { pinned: stri const posts: any[] = []; for (const entry of entries) { + // Skip the 'assets' folder + if (entry.isDirectory() && entry.name === 'assets' && relDir === '') { + continue; + } const fullPath = path.join(dir, entry.name); const relPath = relDir ? path.join(relDir, entry.name) : entry.name; diff --git a/src/app/page.tsx b/src/app/page.tsx index 0c032ff..32abdf0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -148,11 +148,21 @@ export default function Home() { })), ]; + // Helper to strip YAML frontmatter + function stripFrontmatter(md: string): string { + if (!md) return ''; + if (md.startsWith('---')) { + const end = md.indexOf('---', 3); + if (end !== -1) return md.slice(end + 3).replace(/^\s+/, ''); + } + return md; + } + // Helper to recursively collect all posts from the tree function collectPosts(nodes: Node[]): Post[] { let posts: Post[] = []; for (const node of nodes) { - if (node.type === 'post') { + if (node.type === 'post' && node.slug !== 'about') { posts.push(node); } else if (node.type === 'folder') { posts = posts.concat(collectPosts(node.children)); @@ -258,7 +268,7 @@ export default function Home() { )}
Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}
-

{post.summary}

+

{stripFrontmatter(post.summary)}

{post.tags.map((tag: string) => { const q = search.trim().toLowerCase(); @@ -317,7 +327,7 @@ export default function Home() { {/* Posts */} {(() => { - const posts = nodes.filter((n) => n.type === 'post'); + const posts = nodes.filter((n) => n.type === 'post' && n.slug !== 'about'); const pinnedPosts = posts.filter((post: any) => post.pinned); const unpinnedPosts = posts.filter((post: any) => !post.pinned); return [...pinnedPosts, ...unpinnedPosts].map((post: any) => ( @@ -341,7 +351,7 @@ export default function Home() { )}
Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}
-

{post.summary}

+

{stripFrontmatter(post.summary)}

{post.tags.map((tag: string) => (