Merge pull request 'vim' (#12) from vim into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s

Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/12

holy hell if this goes wrong im cooked
This commit is contained in:
2025-07-05 20:25:39 +00:00
21 changed files with 1575 additions and 131 deletions

105
README.md
View File

@@ -20,6 +20,9 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, **
- **🎯 Content Management**: Drag & drop file uploads, post editing, and deletion
- **📦 Export Functionality**: Export all posts as tar.gz archive (Docker only)
- **💾 Smart Caching**: RAM-based caching system for instant post retrieval
- **🔧 VS Code-Style Editor**: Monaco editor with YAML frontmatter support and live preview
- **🔄 Force Reparse**: Manual cache clearing and post reparsing for immediate updates
- **📁 Reliable Directory Scanning**: Robust file system traversal with error handling
---
@@ -43,13 +46,15 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, **
markdownblog/
├── markdown_backend/ # Rust backend for markdown processing
│ ├── src/
│ │ ├── main.rs # CLI interface and command handling
│ │ └── markdown.rs # Markdown parsing, caching, and file watching
│ ├── Cargo.toml # Rust dependencies and configuration
│ └── target/ # Compiled Rust binaries
│ │ ├── main.rs # CLI interface and command handling
│ │ └── markdown.rs # Markdown parsing, caching, and file watching
│ ├── Cargo.toml # Rust dependencies and configuration
│ └── target/ # Compiled Rust binaries
├── src/
│ ├── app/ # Next.js 14 App Router
│ ├── app/ # Next.js 14 App Router
│ │ ├── admin/ # Admin dashboard pages
│ │ │ ├── editor/ # VS Code-style editor
│ │ │ │ └── page.tsx # Markdown editor with Monaco
│ │ │ ├── manage/ # Content management interface
│ │ │ │ ├── page.tsx # Manage posts and folders
│ │ │ │ └── rust-status/ # Rust backend monitoring
@@ -84,8 +89,12 @@ markdownblog/
│ │ │ └── posts/ # Public post API
│ │ │ ├── [slug]/ # Dynamic post API routes
│ │ │ │ └── route.ts
│ │ │ ├── preview/ # Markdown preview API
│ │ │ │ └── route.ts
│ │ │ ├── stream/ # Server-Sent Events for real-time updates
│ │ │ │ └── route.ts
│ │ │ ├── webhook/ # Webhook endpoint
│ │ │ │ └── route.ts
│ │ │ └── route.ts # List all posts
│ │ ├── posts/ # Blog post pages
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
@@ -97,15 +106,15 @@ markdownblog/
│ │ ├── highlight-github.css # Code syntax highlighting styles
│ │ ├── layout.tsx # Root layout with metadata
│ │ ├── MobileNav.tsx # Mobile navigation component
│ │ ├── monaco-vim.d.ts # Monaco Vim typings
│ │ └── page.tsx # Homepage with post listing
│ └── lib/ # Utility libraries
│ └── postsDirectory.ts # Post directory management and Rust integration
├── posts/ # Markdown blog posts storage
│ ├── pinned.json # Pinned posts configuration
│ ├── welcome.md # Welcome post with frontmatter
── mdtest.md # Test post with various markdown features
├── anchor-test.md # Test post for anchor linking
│ └── ii/ # Example nested folder structure
│ ├── about.md
│ ├── welcome.md
── assets/
└── peta.png
├── public/ # Static assets
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
@@ -115,19 +124,19 @@ markdownblog/
│ ├── favicon.ico
│ └── site.webmanifest
├── electron/ # Desktop application
│ └── main.js # Electron main process configuration
│ └── main.js # Electron main process configuration
├── Dockerfile # Docker container configuration
├── docker.sh # Docker deployment script
├── entrypoint.sh # Container entrypoint script
├── run-local-backend.sh # Local Rust backend runner
├── next-env.d.ts # Next.js TypeScript definitions
├── next.config.js # Next.js configuration
├── package-lock.json # npm lock file
├── package.json # Dependencies and scripts
├── postcss.config.js # PostCSS configuration
├── tailwind.config.js # Tailwind CSS configuration
├── tsconfig.json # TypeScript configuration
└── LICENSE # MIT License
├── entrypoint.sh # Container entrypoint script
├── run-local-backend.sh # Local Rust backend runner
├── next-env.d.ts # Next.js TypeScript definitions
├── next.config.js # Next.js configuration
├── package-lock.json # npm lock file
├── package.json # Dependencies and scripts
├── postcss.config.js # PostCSS configuration
├── tailwind.config.js # Tailwind CSS configuration
├── tsconfig.json # TypeScript configuration
└── LICENSE # MIT License
```
### Key Components
@@ -329,6 +338,9 @@ console.log("Hello, World!");
- **📦 Export Posts**: Download all posts as archive (Docker only)
- **📊 Rust Status**: Monitor parser performance, logs, and health
- **🔍 Log Management**: View, filter, and clear parser logs
- **🔧 VS Code Editor**: Monaco-based editor with YAML frontmatter preservation
- **🔄 Force Reparse**: Manual cache clearing and post reparsing
- **📁 Reliable Scanning**: Enhanced directory traversal with error recovery
### Security
@@ -350,6 +362,9 @@ console.log("Hello, World!");
- **📁 Recursive Scanning**: Efficient folder traversal and file discovery
- **💾 Smart Caching**: RAM-based caching with disk persistence
- **📊 Performance Monitoring**: Real-time metrics and logging
- **🔄 Force Reparse**: Manual cache invalidation and post reparsing
- **📁 Reliable Directory Scanning**: Robust error handling and recovery
- **🔧 Single Post Reparse**: Efficient individual post cache clearing
### Real-Time Updates
@@ -358,6 +373,16 @@ console.log("Hello, World!");
- **⚡ Instant Updates**: Sub-second response to file modifications
- **🔄 Fallback Polling**: Graceful degradation if SSE fails
### VS Code-Style Editor
- **🔧 Monaco Editor**: Professional code editor with syntax highlighting
- **📄 YAML Frontmatter**: Preserved and editable at the top of files
- **👁️ Live Preview**: Real-time Markdown rendering
- **💾 Save & Reparse**: Automatic cache clearing and post reparsing
- **⌨️ Vim Mode**: Optional Vim keybindings for power users
- **📱 Responsive Design**: Works on desktop and mobile devices
- **🎨 Custom Styling**: JetBrains Mono font and VS Code-like appearance
---
## 🎨 Customization
@@ -398,6 +423,8 @@ cargo build --release # Build optimized binary
cargo run -- watch # Watch for file changes
cargo run -- logs # View parser logs
cargo run -- checkhealth # Check backend health
cargo run -- reinterpret-all # Force reparse all posts
cargo run -- reparse-post <slug> # Force reparse single post
```
---
@@ -434,7 +461,43 @@ MIT License - see [LICENSE](LICENSE) file for details.
- **File watching issues**: Check file permissions and inotify limits
- **Performance issues**: Monitor logs via admin interface
- **Cache problems**: Clear cache via admin interface or restart
- **Directory scanning errors**: Check file permissions and hidden files
- **Reparse failures**: Verify post slugs and file existence
- **Memory issues**: Monitor cache size and clear if necessary
### Support
For issues and questions, please check the project structure and API documentation in the codebase. The admin interface includes comprehensive monitoring tools for the Rust backend.
---
## 🆕 Recent Improvements (Latest)
### Rust Backend Enhancements
- **🔄 Force Reparse Commands**: New CLI commands for manual cache invalidation
- `reinterpret-all`: Clear all caches and reparse every post
- `reparse-post <slug>`: Clear cache for specific post and reparse
- **📁 Reliable Directory Scanning**: Enhanced file system traversal with:
- Hidden file filtering (skips `.` files)
- Graceful error recovery for inaccessible files
- Detailed logging of scanning process
- Automatic directory creation if missing
- **💾 Improved Cache Management**: Better cache directory handling and persistence
- **📊 Enhanced Logging**: Comprehensive logging for debugging and monitoring
### Editor Improvements
- **🔧 VS Code-Style Interface**: Monaco editor with professional features
- **📄 YAML Frontmatter Preservation**: Frontmatter stays at top and remains editable
- **💾 Save & Reparse Integration**: Automatic Rust backend integration on save
- **👁️ Live Preview**: Real-time Markdown rendering without frontmatter
- **⌨️ Vim Mode Support**: Optional Vim keybindings for power users
- **📱 Mobile Responsive**: Works seamlessly on all device sizes
### Admin Panel Enhancements
- **🔄 Force Reparse Button**: One-click cache clearing and post reparsing
- **📊 Enhanced Rust Status**: Real-time parser performance monitoring
- **🔍 Improved Log Management**: Better filtering and search capabilities
- **📁 Directory Health Monitoring**: Comprehensive file system diagnostics

View File

@@ -1,7 +1,17 @@
#[warn(unused_imports)]
use clap::{Parser, Subcommand};
mod markdown;
use markdown::{get_all_posts, get_post_by_slug, get_posts_by_tag, watch_posts, get_parser_logs, clear_parser_logs, load_parser_logs_from_disk};
use markdown::{
get_all_posts,
get_post_by_slug,
get_posts_by_tag,
watch_posts,
get_parser_logs,
clear_parser_logs,
load_parser_logs_from_disk,
force_reinterpret_all_posts,
force_reparse_single_post
};
use serde_json;
use std::fs;
use std::io;
@@ -44,6 +54,12 @@ enum Commands {
Logs,
/// Clear parser logs
ClearLogs,
/// Force reinterpret all posts (clear cache and re-parse)
ReinterpretAll,
/// Force reparse a single post (clear cache and re-parse)
ReparsePost {
slug: String,
},
/// Parse markdown from file or stdin
Parse {
#[arg(long)]
@@ -111,6 +127,35 @@ fn main() {
clear_parser_logs();
println!("{}", serde_json::to_string(&serde_json::json!({"success": true, "message": "Logs cleared"})).unwrap());
}
Commands::ReinterpretAll => {
match force_reinterpret_all_posts() {
Ok(posts) => {
println!("{}", serde_json::to_string(&serde_json::json!({
"success": true,
"message": format!("All posts reinterpreted successfully. Processed {} posts.", posts.len())
})).unwrap());
}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
Commands::ReparsePost { slug } => {
match force_reparse_single_post(slug) {
Ok(post) => {
println!("{}", serde_json::to_string(&serde_json::json!({
"success": true,
"message": format!("Post '{}' reparsed successfully", slug),
"post": post
})).unwrap());
}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
Commands::Parse { file, stdin, ast } => {
let content = if *stdin {
let mut buffer = String::new();

View File

@@ -180,6 +180,18 @@ static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
});
// Helper functions
fn ensure_cache_directory() {
let cache_dir = PathBuf::from("./cache");
if !cache_dir.exists() {
if let Err(e) = fs::create_dir_all(&cache_dir) {
eprintln!("Failed to create cache directory: {}", e);
add_log("error", &format!("Failed to create cache directory: {}", e), None, None);
} else {
add_log("info", "Created cache directory: ./cache", None, None);
}
}
}
fn get_posts_directory() -> PathBuf {
let is_docker = std::env::var("DOCKER_CONTAINER").is_ok()
|| std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
@@ -207,29 +219,101 @@ fn get_posts_directory() -> PathBuf {
for candidate in candidates.iter() {
let path = PathBuf::from(candidate);
if path.exists() && path.is_dir() {
add_log("info", &format!("Using posts directory: {:?}", path), None, None);
return path;
}
}
// Fallback: default to ./posts
PathBuf::from("./posts")
// Fallback: create ./posts if it doesn't exist
let fallback_path = PathBuf::from("./posts");
if !fallback_path.exists() {
if let Err(e) = fs::create_dir_all(&fallback_path) {
add_log("error", &format!("Failed to create posts directory: {}", e), None, None);
} else {
add_log("info", "Created posts directory: ./posts", None, None);
}
}
fallback_path
}
// Function to find Markdown files.
// This will scan Directories recursively
// Function to find Markdown files with improved reliability
fn find_markdown_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let mut errors = Vec::new();
if path.is_dir() {
files.extend(find_markdown_files(&path)?);
} else if path.extension().map(|e| e == "md").unwrap_or(false) {
files.push(path);
if !dir.exists() {
let error_msg = format!("Directory does not exist: {:?}", dir);
add_log("error", &error_msg, None, None);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, error_msg));
}
if !dir.is_dir() {
let error_msg = format!("Path is not a directory: {:?}", dir);
add_log("error", &error_msg, None, None);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, error_msg));
}
// Try to read directory with retry logic
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
add_log("error", &format!("Failed to read directory {:?}: {}", dir, e), None, None);
return Err(e);
}
};
for entry_result in entries {
match entry_result {
Ok(entry) => {
let path = entry.path();
// Skip hidden files and directories
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
continue;
}
}
if path.is_dir() {
// Recursively scan subdirectories
match find_markdown_files(&path) {
Ok(subfiles) => files.extend(subfiles),
Err(e) => {
let error_msg = format!("Error scanning subdirectory {:?}: {}", path, e);
add_log("warning", &error_msg, None, None);
errors.push(error_msg);
}
}
} else if path.extension().map(|e| e == "md").unwrap_or(false) {
// Verify the file is readable
match fs::metadata(&path) {
Ok(metadata) => {
if metadata.is_file() {
files.push(path);
}
}
Err(e) => {
let error_msg = format!("Cannot access file {:?}: {}", path, e);
add_log("warning", &error_msg, None, None);
errors.push(error_msg);
}
}
}
}
Err(e) => {
let error_msg = format!("Error reading directory entry: {}", e);
add_log("warning", &error_msg, None, None);
errors.push(error_msg);
}
}
}
// Log summary
add_log("info", &format!("Found {} markdown files in {:?}", files.len(), dir), None, None);
if !errors.is_empty() {
add_log("warning", &format!("Encountered {} errors during directory scan", errors.len()), None, None);
}
Ok(files)
}
@@ -372,7 +456,7 @@ fn add_log(level: &str, message: &str, slug: Option<&str>, details: Option<&str>
}
fn save_parser_logs_to_disk_inner(logs: &VecDeque<LogEntry>) -> std::io::Result<()> {
let _ = std::fs::create_dir_all("./cache");
ensure_cache_directory();
let logs_vec: Vec<_> = logs.iter().cloned().collect();
let json = serde_json::to_string(&logs_vec)?;
std::fs::write(PARSER_LOGS_PATH, json)?;
@@ -654,12 +738,11 @@ pub fn load_post_cache_from_disk() {
}
pub fn save_post_cache_to_disk() {
ensure_cache_directory();
if let Ok(map) = serde_json::to_string(&*POST_CACHE.read().unwrap()) {
let _ = fs::create_dir_all("./cache");
let _ = fs::write(POSTS_CACHE_PATH, map);
}
if let Ok(map) = serde_json::to_string(&*POST_STATS.read().unwrap()) {
let _ = fs::create_dir_all("./cache");
let _ = fs::write(POST_STATS_PATH, map);
}
}
@@ -739,7 +822,90 @@ pub fn get_parser_logs() -> Vec<LogEntry> {
}
pub fn clear_parser_logs() {
let mut logs = PARSER_LOGS.write().unwrap();
logs.clear();
let _ = std::fs::remove_file(PARSER_LOGS_PATH);
PARSER_LOGS.write().unwrap().clear();
if let Err(e) = save_parser_logs_to_disk_inner(&VecDeque::new()) {
eprintln!("Failed to save empty logs to disk: {}", e);
}
}
// Force reinterpret all posts by clearing cache and re-parsing
pub fn force_reinterpret_all_posts() -> Result<Vec<Post>, Box<dyn std::error::Error>> {
add_log("info", "Starting force reinterpret of all posts", None, None);
// Clear all caches
POST_CACHE.write().unwrap().clear();
ALL_POSTS_CACHE.write().unwrap().take();
POST_STATS.write().unwrap().clear();
add_log("info", "Cleared all caches", None, None);
// Get posts directory and find all markdown files
let posts_dir = get_posts_directory();
let markdown_files = find_markdown_files(&posts_dir)?;
add_log("info", &format!("Found {} markdown files to reinterpret", markdown_files.len()), None, None);
let mut posts = Vec::new();
let mut success_count = 0;
let mut error_count = 0;
for file_path in markdown_files {
let slug = path_to_slug(&file_path, &posts_dir);
match get_post_by_slug(&slug) {
Ok(post) => {
posts.push(post);
success_count += 1;
add_log("info", &format!("Successfully reinterpreted: {}", slug), Some(&slug), None);
}
Err(e) => {
error_count += 1;
add_log("error", &format!("Failed to reinterpret {}: {}", slug, e), Some(&slug), None);
}
}
}
// Update the all posts cache
ALL_POSTS_CACHE.write().unwrap().replace(posts.clone());
// Save cache to disk
save_post_cache_to_disk();
add_log("info", &format!("Force reinterpret completed. Success: {}, Errors: {}", success_count, error_count), None, None);
Ok(posts)
}
// Force reparse a single post by clearing its cache and re-parsing
pub fn force_reparse_single_post(slug: &str) -> Result<Post, Box<dyn std::error::Error>> {
add_log("info", &format!("Starting force reparse of post: {}", slug), Some(slug), None);
// Clear this specific post from all caches
POST_CACHE.write().unwrap().remove(slug);
POST_STATS.write().unwrap().remove(slug);
// Clear the all posts cache since it might contain this post
ALL_POSTS_CACHE.write().unwrap().take();
add_log("info", &format!("Cleared cache for post: {}", slug), Some(slug), None);
// Re-parse the post
let post = get_post_by_slug(slug)?;
// Update the all posts cache with the new post
let mut all_posts_cache = ALL_POSTS_CACHE.write().unwrap();
if let Some(ref mut posts) = *all_posts_cache {
// Remove old version if it exists
posts.retain(|p| p.slug != slug);
// Add new version
posts.push(post.clone());
// Sort by creation date
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
}
// Save cache to disk
save_post_cache_to_disk();
add_log("info", &format!("Successfully reparsed post: {}", slug), Some(slug), None);
Ok(post)
}

199
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "markdownblog",
"version": "0.1.0",
"dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
@@ -23,8 +25,11 @@
"emoji-picker-react": "^4.12.2",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^24.0.0",
"marked": "^12.0.0",
"monaco-editor": "^0.52.2",
"monaco-vim": "^0.4.2",
"next": "14.1.0",
"pm2": "^6.0.8",
"postcss": "^8.4.35",
@@ -304,6 +309,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.6.tgz",
"integrity": "sha512-nz//dBr99hXZmHp10wgNI00qThWImkzRR5PQjvRM+rpmuHO5rYBJCqPPWufidCvmkkryXx/GOP/lgqsM3R3Org==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -542,6 +556,29 @@
"node": ">=10"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@next/env": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
@@ -1176,19 +1213,6 @@
"parse5": "^7.0.0"
}
},
"node_modules/@types/jsdom/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -5147,6 +5171,92 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/isomorphic-dompurify": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz",
"integrity": "sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.2.6",
"jsdom": "^26.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/isomorphic-dompurify/node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/isomorphic-dompurify/node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/isomorphic-dompurify/node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -5291,18 +5401,6 @@
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/jsdom/node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
@@ -5664,6 +5762,21 @@
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT"
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT"
},
"node_modules/monaco-vim": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.2.tgz",
"integrity": "sha512-rdbQC3O2rmpwX2Orzig/6gZjZfH7q7TIeB+uEl49sa+QyNm3jCKJOw5mwxBdFzTqbrPD+URfg6A2lEkuL5kymw==",
"license": "MIT",
"peerDependencies": {
"monaco-editor": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6263,6 +6376,18 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7590,6 +7715,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8189,6 +8320,24 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -11,6 +11,8 @@
"electron-dev": "concurrently \"npm run dev\" \"npm run electron\""
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
@@ -26,8 +28,11 @@
"emoji-picker-react": "^4.12.2",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^24.0.0",
"marked": "^12.0.0",
"monaco-editor": "^0.52.2",
"monaco-vim": "^0.4.2",
"next": "14.1.0",
"pm2": "^6.0.8",
"postcss": "^8.4.35",

40
posts/about.md Normal file
View File

@@ -0,0 +1,40 @@
---
title: About Me
date: 2025-07-04
tags: [about, profile]
author: rattatwinko
summary: This is the about page
---
# About Me
_**I am rattatwinko**_
I created this Project because of the lack of Blog's that use Markdown.
It really is sad that there are so many blog platforms which are shit.
## What I used:
- TypeScript
- Next.JS
- Rust
- Monaco (for a beautiful Editing experience)
- More shit which you can check out in the Repo
## What I do
School.
Coding.
Not more not less.
### Socials
<!-- HTML for this cause Markdown does not support this -->
<form action="https://instagram.com/rattatwinko">
<input type="submit" value="Insta" />
</form>
<form action="https://tiktok.com/rattatwinko">
<input type="submit" value="TikTok" />
</form>

BIN
posts/assets/peta.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

View File

@@ -1,5 +1,6 @@
'use client';
import BadgeButton from './BadgeButton';
import { useRouter } from 'next/navigation';
const InfoIcon = (
<svg width="16" height="16" fill="white" viewBox="0 0 16 16" aria-hidden="true">
@@ -9,16 +10,13 @@ const InfoIcon = (
);
export default function AboutButton() {
const router = useRouter();
return (
<BadgeButton
label="ABOUT ME"
color="#2563eb"
icon={InfoIcon}
onClick={() => {
if (typeof window !== 'undefined') {
window.open('http://' + window.location.hostname + ':80', '_blank');
}
}}
onClick={() => router.push('/posts/about')}
/>
);
}

View File

@@ -33,7 +33,7 @@ export default function HeaderButtons() {
/>
</a>
<a
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
href="/posts/about"
target="_self"
rel="noopener noreferrer"
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"

View File

@@ -70,13 +70,13 @@ export default function MobileNav({ blogOwner }: MobileNavProps) {
🔐 Admin
</Link>
<a
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
<Link
href="/posts/about"
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
onClick={toggleMenu}
>
👤 About Me
</a>
</Link>
</nav>
<div className="mt-8 pt-6 border-t border-gray-200">

98
src/app/about/page.tsx Normal file
View File

@@ -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<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Lade About...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Fehler beim Laden</h1>
<p className="text-gray-600 mb-6">{error}</p>
</div>
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">About nicht gefunden</h1>
<p className="text-gray-600 mb-6">Die About-Seite konnte nicht gefunden werden.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<article className="bg-white rounded-lg shadow-sm border p-6 sm:p-8">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
{post.title || "About"}
</h1>
{post.summary && (
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
{post.summary}
</p>
)}
</header>
<div
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 prose-code:text-gray-800 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-900 prose-pre:text-gray-100"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</main>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import Editor from "@monaco-editor/react";
export default function MonacoEditorWrapper(props: any) {
return (
<Editor
height="600px"
defaultLanguage="markdown"
defaultValue={props.defaultValue || ""}
{...props}
/>
);
}

View File

@@ -0,0 +1,621 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
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;
}
// Helper to extract YAML frontmatter
function extractFrontmatter(md: string): { frontmatter: string; content: string } {
if (!md) return { frontmatter: '', content: '' };
if (md.startsWith('---')) {
const end = md.indexOf('---', 3);
if (end !== -1) {
const frontmatter = md.slice(0, end + 3);
const content = md.slice(end + 3).replace(/^\s+/, '');
return { frontmatter, content };
}
}
return { frontmatter: '', content: md };
}
// Helper to combine frontmatter and content
function combineFrontmatterAndContent(frontmatter: string, content: string): string {
if (!frontmatter) return content;
if (!content) return frontmatter;
return frontmatter + '\n\n' + content;
}
function FileTree({ nodes, onSelect, selectedSlug, level = 0 }: {
nodes: Node[];
onSelect: (slug: string) => void;
selectedSlug: string | null;
level?: number;
}) {
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({});
return (
<ul className="pl-2">
{nodes.map((node) => {
if (node.type === "folder") {
const isOpen = openFolders[node.path] ?? true;
return (
<li key={node.path} className="mb-1">
<button
className="flex items-center gap-1 text-gray-700 hover:bg-gray-100 rounded px-1 py-0.5 w-full"
style={{ paddingLeft: 8 + level * 12 }}
onClick={() => setOpenFolders(f => ({ ...f, [node.path]: !isOpen }))}
>
<span className="text-lg">{node.emoji || "📁"}</span>
<span className="font-semibold text-sm">{node.name}</span>
<span className="ml-auto text-xs">{isOpen ? "▼" : "▶"}</span>
</button>
{isOpen && (
<FileTree nodes={node.children} onSelect={onSelect} selectedSlug={selectedSlug} level={level + 1} />
)}
</li>
);
} else {
return (
<li key={node.slug}>
<button
className={`flex items-center gap-2 px-2 py-1 rounded w-full text-left text-sm font-mono ${selectedSlug === node.slug ? "bg-blue-100 text-blue-800 font-bold" : "hover:bg-gray-100 text-gray-800"}`}
style={{ paddingLeft: 8 + level * 12 }}
onClick={() => onSelect(node.slug)}
title={node.title}
>
<span className="text-gray-400">📝</span>
<span className="truncate">{node.title || node.slug}</span>
</button>
</li>
);
}
})}
</ul>
);
}
export default function EditorPage() {
// State
const router = useRouter();
const [tree, setTree] = useState<Node[]>([]);
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [fileTitle, setFileTitle] = useState<string>("");
const [vimMode, setVimMode] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string>("");
const [split, setSplit] = useState(50); // percent - default to 50/50 split
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [browserOpen, setBrowserOpen] = useState(true);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
const editorRef = useRef<any>(null);
const monacoVimRef = useRef<any>(null);
// Check if there are unsaved changes
const hasUnsavedChanges = fileContent !== originalContent;
// Handle browser beforeunload event
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
// Handle back navigation with unsaved changes check
const handleBackNavigation = () => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
setPendingNavigation('/admin');
} else {
router.push('/admin');
}
};
// Handle unsaved changes dialog actions
const handleUnsavedDialogAction = (action: 'save' | 'discard' | 'cancel') => {
if (action === 'save') {
handleSave().then(() => {
setOriginalContent(fileContent); // Reset unsaved state after save
setShowUnsavedDialog(false);
setPendingNavigation(null);
if (pendingNavigation) {
router.push(pendingNavigation);
}
});
} else if (action === 'discard') {
setFileContent(originalContent); // Revert to last saved
setShowUnsavedDialog(false);
setPendingNavigation(null);
if (pendingNavigation) {
router.push(pendingNavigation);
}
} else {
setShowUnsavedDialog(false);
setPendingNavigation(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 => {
const { frontmatter, content } = extractFrontmatter(data.raw || data.content || "");
const combinedContent = combineFrontmatterAndContent(frontmatter, content);
setFileContent(combinedContent);
setOriginalContent(combinedContent);
setFileTitle(data.title || data.slug || "");
setLoading(false);
});
}, [selectedSlug]);
// Save file
async function handleSave() {
if (!selectedSlug) return;
setSaving(true);
try {
// First save the file
const saveResponse = await fetch(`/api/posts/${encodeURIComponent(selectedSlug)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markdown: fileContent })
});
if (!saveResponse.ok) {
throw new Error('Failed to save file');
}
// Then call Rust backend to reparse this specific post
const reparseResponse = await fetch(`/api/admin/posts?reparsePost=${encodeURIComponent(selectedSlug)}`);
if (!reparseResponse.ok) {
console.warn('Failed to reparse post, but file was saved');
} else {
console.log('Post saved and reparsed successfully');
}
setOriginalContent(fileContent); // Reset unsaved state after save
} catch (error) {
console.error('Error saving/reparsing post:', error);
} finally {
setSaving(false);
}
}
// Live preview (JS markdown, not Rust)
useEffect(() => {
if (!fileContent) { setPreviewHtml(""); return; }
const { content } = extractFrontmatter(fileContent);
const html = typeof marked.parse === 'function' ? marked.parse(content) : '';
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;
// Ensure editor resizes properly
const resizeObserver = new ResizeObserver(() => {
editor.layout();
});
const editorContainer = editor.getContainerDomNode();
if (editorContainer) {
resizeObserver.observe(editorContainer);
}
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);
const [isDragging, setIsDragging] = useState(false);
function onDrag(e: React.MouseEvent | MouseEvent) {
if (!dragRef.current) return;
const percent = (e.clientX / window.innerWidth) * 100;
setSplit(percent); // No min/max limits
}
function onDragStart() {
dragRef.current = true;
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
function onDragEnd() {
dragRef.current = false;
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
useEffect(() => {
function onMove(e: MouseEvent) {
if (dragRef.current) {
onDrag(e);
}
}
function onUp() {
if (dragRef.current) {
onDragEnd();
}
}
if (isDragging) {
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}
}, [isDragging]);
// Layout logic for left pane (file browser + editor)
const leftPaneWidth = `${split}%`;
const fileBrowserWidth = 240;
// Only render MonacoEditor if the editor pane is visible and has width
const showEditor = true; // Always show editor, it will resize based on container
return (
<div className="h-screen w-screen bg-white flex flex-col font-mono" style={{ fontFamily: 'JetBrains Mono, monospace', fontWeight: 'bold' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-2">
{/* VS Code SVG Icon (smaller) */}
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
style={{ minWidth: 20, minHeight: 20, width: 24, height: 24 }}
>
<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.30225 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="text-black text-lg font-semibold">Markdown Bearbeiter</span>
</div>
<div className="flex gap-2 items-center">
{/* Back Button */}
<button
onClick={() => handleBackNavigation()}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border font-mono text-sm sm:text-base ${
saving
? 'opacity-60 cursor-wait border-red-400 bg-red-500 text-white'
: hasUnsavedChanges
? 'border-orange-400 bg-orange-500 text-white hover:bg-orange-600'
: 'border-red-400 bg-red-500 text-white hover:bg-red-600'
}`}
disabled={saving}
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" 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="hidden sm:inline">
{hasUnsavedChanges ? 'Zurück*' : 'Zurück'}
</span>
</button>
{/* Save Button */}
<button
onClick={handleSave}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border font-mono text-sm sm:text-base ${
saving
? 'opacity-60 cursor-wait border-blue-400 bg-blue-500 text-white'
: hasUnsavedChanges
? 'border-orange-400 bg-orange-500 text-white hover:bg-orange-600'
: 'border-blue-400 bg-blue-500 text-white hover:bg-blue-600'
}`}
disabled={saving}
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H7a2 2 0 01-2-2V7a2 2 0 012-2h4a2 2 0 012 2v1" /></svg>
<span className="hidden sm:inline">
{saving
? 'Am Speichern...'
: hasUnsavedChanges
? 'Speichern*'
: 'Speichern'
}
</span>
</button>
{/* Vim Mode Button */}
<button
onClick={() => setVimMode((v) => !v)}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border border-gray-300 text-sm sm:text-base ${
vimMode
? "bg-green-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-100"
}`}
>
{/* Actual Vim SVG Icon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor">
<title>vim</title>
<path d="M26.445 22.095l0.592-0.649h1.667l0.386 0.519-1.581 5.132h0.616l-0.1 0.261h-2.228l1.405-4.454h-2.518l-1.346 4.238h0.53l-0.091 0.217h-2.006l1.383-4.434h-2.619l-1.327 4.172h0.545l-0.090 0.261h-2.076l1.892-5.573h-0.732l0.114-0.339h2.062l0.649 0.671h1.132l0.614-0.692h1.326l0.611 0.669zM7.99 27.033h-2.141l-0.327-0.187v-21.979h-1.545l-0.125-0.125v-1.47l0.179-0.192h9.211l0.266 0.267v1.385l-0.177 0.216h-1.348v10.857l11.006-10.857h-2.607l-0.219-0.235v-1.453l0.151-0.139h9.36l0.165 0.166v1.337l-12.615 12.937h-0.466c-0.005-0-0.011-0-0.018-0-0.012 0-0.024 0.001-0.036 0.002l0.002-0-0.025 0.004c-0.058 0.012-0.108 0.039-0.149 0.075l0-0-0.429 0.369-0.005 0.004c-0.040 0.037-0.072 0.084-0.090 0.136l-0.001 0.002-0.37 1.037zM17.916 18.028l0.187 0.189-0.336 1.152-0.281 0.282h-1.211l-0.226-0.226 0.389-1.088 0.36-0.309zM13.298 27.42l1.973-5.635h-0.626l0.371-0.38h2.073l-1.953 5.692h0.779l-0.099 0.322zM30.996 15.982h-0.034l-5.396-5.396 5.377-5.516v-2.24l-0.811-0.81h-10.245l-0.825 0.756v1.306l-3.044-3.044v-0.034l-0.019 0.018-0.018-0.018v0.034l-1.612 1.613-0.672-0.673h-10.151l-0.797 0.865v2.356l0.77 0.77h0.9v6.636l-3.382 3.38h-0.034l0.018 0.016-0.018 0.017h0.034l3.382 3.382v8.081l1.133 0.654h2.902l2.321-2.379 5.206 5.206v0.035l0.019-0.017 0.017 0.017v-0.035l3.136-3.135h0.606c0.144-0.001 0.266-0.093 0.312-0.221l0.001-0.002 0.182-0.532c0.011-0.031 0.017-0.067 0.017-0.105 0-0.073-0.024-0.14-0.064-0.195l0.001 0.001 1.827-1.827-0.765 2.452c-0.009 0.029-0.015 0.063-0.015 0.097 0 0.149 0.098 0.275 0.233 0.317l0.002 0.001c0.029 0.009 0.063 0.015 0.097 0.015 0 0 0 0 0 0h2.279c0.136-0.001 0.252-0.084 0.303-0.201l0.001-0.002 0.206-0.492c0.014-0.036 0.022-0.077 0.022-0.121 0-0.048-0.010-0.094-0.028-0.135l0.001 0.002c-0.035-0.082-0.1-0.145-0.18-0.177l-0.002-0.001c-0.036-0.015-0.077-0.024-0.121-0.025h-0.094l1.050-3.304h1.54l-1.27 4.025c-0.009 0.029-0.015 0.063-0.015 0.097 0 0.149 0.098 0.274 0.232 0.317l0.002 0.001c0.029 0.009 0.063 0.015 0.098 0.015 0 0 0.001 0 0.001 0h2.502c0 0 0.001 0 0.001 0 0.14 0 0.26-0.087 0.308-0.21l0.001-0.002 0.205-0.535c0.013-0.034 0.020-0.073 0.020-0.114 0-0.142-0.090-0.264-0.215-0.311l-0.002-0.001c-0.034-0.013-0.073-0.021-0.114-0.021h-0.181l1.413-4.59c0.011-0.031 0.017-0.066 0.017-0.103 0-0.074-0.025-0.143-0.066-0.198l0.001 0.001-0.469-0.63-0.004-0.006c-0.061-0.078-0.156-0.127-0.261-0.127h-1.795c-0.093 0-0.177 0.039-0.237 0.101l-0 0-0.5 0.549h-0.78l-0.052-0.057 5.555-5.555h0.035l-0.017-0.014z"/>
</svg>
<span className="hidden sm:inline">Vim Modus</span>
</button>
</div>
</div>
{/* Split Layout */}
<div className="flex flex-1 min-h-0" style={{ userSelect: isDragging ? "none" : undefined }}>
{/* Left: File browser + Editor */}
<div className="flex flex-row h-full bg-white" style={{ width: leftPaneWidth, minWidth: 0, maxWidth: '100%' }}>
{/* File Browser Collapsible Toggle */}
<div style={{ width: 32, minWidth: 32, maxWidth: 32, display: 'flex', flexDirection: 'column' }}>
<button
className={`w-full flex items-center justify-center px-2 py-1 font-mono hover:bg-gray-200 focus:outline-none ${
browserOpen
? 'bg-gray-100 border-b border-gray-200 text-gray-700'
: 'bg-gray-200 border-b border-gray-300 text-gray-600 hover:bg-gray-300'
}`}
onClick={() => setBrowserOpen(o => !o)}
style={{ fontFamily: 'JetBrains Mono, monospace', height: 40 }}
title={browserOpen ? "Datei-Explorer ausblenden" : "Datei-Explorer anzeigen"}
>
<span className="text-lg" style={{ transform: browserOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}>
</span>
</button>
</div>
{/* File browser content */}
{browserOpen && (
<div className="border-r border-gray-200 bg-gray-50 text-gray-800 font-mono overflow-auto" style={{ width: fileBrowserWidth, minWidth: fileBrowserWidth, maxWidth: fileBrowserWidth }}>
<div className="h-64 p-2">
{tree.length === 0 ? (
<div className="text-xs text-gray-400">Keine Datein gefunden.</div>
) : (
<FileTree nodes={tree} onSelect={setSelectedSlug} selectedSlug={selectedSlug} />
)}
</div>
</div>
)}
{/* Monaco Editor */}
<div className="flex-1 p-0 overflow-auto" style={{ fontFamily: 'JetBrains Mono, monospace', fontWeight: 'bold' }}>
<div className="h-full">
{showEditor && (
<MonacoErrorBoundary>
<MonacoEditor
height="100%"
defaultLanguage="markdown"
value={fileContent}
theme="light"
options={{
fontFamily: 'JetBrains Mono',
fontWeight: 'bold',
fontSize: 15,
minimap: { enabled: true },
wordWrap: "on",
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
lineNumbers: "on",
renderLineHighlight: "all",
scrollbar: { vertical: "auto", horizontal: "auto" },
tabSize: 2,
cursorBlinking: "smooth",
cursorStyle: "line",
fixedOverflowWidgets: true,
readOnly: loading,
folding: true,
foldingStrategy: "indentation",
showFoldingControls: "always",
foldingHighlight: true,
foldingImportsByDefault: true,
unfoldOnClickAfterEndOfLine: false,
links: true,
colorDecorators: true,
formatOnPaste: true,
formatOnType: true,
}}
onChange={v => setFileContent(v ?? "")}
onMount={handleEditorDidMount}
/>
</MonacoErrorBoundary>
)}
<div id="vim-status" className="text-xs text-gray-500 px-2 py-1 bg-gray-100 border-t border-gray-200 font-mono" />
</div>
</div>
</div>
{/* Draggable Splitter - always show */}
<div
className={`w-1 cursor-col-resize transition-colors relative ${
isDragging ? 'bg-blue-500' : 'bg-gray-300 hover:bg-gray-400'
}`}
onMouseDown={onDragStart}
>
{/* Drag handle indicator */}
<div className="absolute inset-y-0 left-1/2 transform -translate-x-1/2 w-4 flex items-center justify-center">
<div className="w-1 h-8 bg-gray-400 rounded-full opacity-50"></div>
</div>
</div>
{/* Right: Live Preview */}
<div className="flex-1 bg-gray-50 p-8 overflow-auto border-l border-gray-200">
<article className="bg-white rounded-lg shadow-sm border p-6 sm:p-8">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
{fileTitle}
</h1>
</header>
<div
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 prose-code:text-gray-800 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-900 prose-pre:text-gray-100"
style={{ fontFamily: 'inherit', fontWeight: 'normal' }}
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</article>
</div>
</div>
{/* Unsaved Changes Dialog */}
{showUnsavedDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Ungespeicherte Änderungen</h3>
</div>
<p className="text-gray-600 mb-6">
Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern, bevor Sie fortfahren?
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => handleUnsavedDialogAction('cancel')}
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"
>
Abbrechen
</button>
<button
onClick={() => handleUnsavedDialogAction('discard')}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Verwerfen
</button>
<button
onClick={() => handleUnsavedDialogAction('save')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Speichern
</button>
</div>
</div>
</div>
)}
</div>
);
}
// 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 <div className="text-red-600 p-4">Fehler: {this.state.error.message}</div>;
}
return this.props.children;
}
}

View File

@@ -103,6 +103,19 @@ export default function RustStatusPage() {
}
};
const reinterpretAllPosts = async () => {
try {
const res = await fetch('/api/admin/posts?reinterpretAll=1');
if (!res.ok) throw new Error('Fehler beim Neuinterpretieren der Posts');
const data = await res.json();
console.log('Reinterpret result:', data);
// Refresh all data after reinterpret
await Promise.all([fetchStats(), fetchHealth(), fetchLogs()]);
} catch (e: any) {
console.error('Error reinterpreting posts:', e);
}
};
useEffect(() => {
fetchStats();
fetchHealth();
@@ -358,6 +371,13 @@ export default function RustStatusPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<h2 className="text-base font-semibold">Parser Logs</h2>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={reinterpretAllPosts}
className="px-2.5 py-1.5 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs transition-colors"
title="Force reinterpret all posts"
>
Reinterpret All
</button>
<button
onClick={clearLogs}
className="px-2.5 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-xs transition-colors"

View File

@@ -1,9 +1,17 @@
'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';
@@ -12,8 +20,21 @@ import Link from 'next/link';
import { marked } from 'marked';
import hljs from 'highlight.js';
import matter from 'gray-matter';
import dynamic from 'next/dynamic';
import dynamicImport from 'next/dynamic';
import { Theme } from 'emoji-picker-react';
import '../highlight-github.css';
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;
@@ -46,7 +67,18 @@ interface Post {
type Node = Post | Folder;
const EmojiPicker = dynamic(() => import('emoji-picker-react'), { ssr: false });
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);
@@ -69,12 +101,7 @@ export default function AdminPage() {
});
const [showManageContent, setShowManageContent] = useState(false);
const [managePath, setManagePath] = useState<string[]>([]);
const [pinned, setPinned] = useState<string[]>(() => {
if (typeof window !== 'undefined') {
return JSON.parse(localStorage.getItem('pinnedPosts') || '[]');
}
return [];
});
const [pinned, setPinned] = useState<string[]>([]);
const [pinFeedback, setPinFeedback] = useState<string | null>(null);
const [showChangePassword, setShowChangePassword] = useState(false);
const [changePwOld, setChangePwOld] = useState('');
@@ -84,12 +111,7 @@ export default function AdminPage() {
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>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('rememberExportChoice') === 'true';
}
return 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);
@@ -97,6 +119,10 @@ export default function AdminPage() {
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
@@ -129,13 +155,7 @@ export default function AdminPage() {
marked.setOptions({
gfm: true,
breaks: true,
highlight: function(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
} else {
return hljs.highlightAuto(code).value;
}
}
renderer,
} as any);
setPreviewHtml(marked.parse(newPost.content || '') as string);
}, [newPost.content]);
@@ -518,15 +538,7 @@ export default function AdminPage() {
};
function handleExportTarball() {
// Check if we should use the remembered choice
if (rememberExportChoice && lastExportChoice) {
if (lastExportChoice === 'docker') {
exportFromEndpoint('/api/admin/export');
} else if (lastExportChoice === 'local') {
exportFromEndpoint('/api/admin/exportlocal');
}
return;
}
if (typeof window === 'undefined') return;
// Create popup modal
const modal = document.createElement('div');
@@ -619,6 +631,7 @@ export default function AdminPage() {
}
function exportFromEndpoint(endpoint: string) {
if (typeof window === 'undefined') return;
fetch(endpoint)
.then(async (res) => {
if (!res.ok) throw new Error('Export failed');
@@ -642,6 +655,15 @@ export default function AdminPage() {
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 {
@@ -752,6 +774,17 @@ export default function AdminPage() {
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 && (
@@ -867,6 +900,60 @@ export default function AdminPage() {
<span className="text-xs font-normal text-teal-100">Statistiken</span>
</span>
</a>
{/* VS Code Editor Button */}
<a
href="/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>
@@ -990,8 +1077,6 @@ export default function AdminPage() {
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 ${
@@ -1072,25 +1157,66 @@ export default function AdminPage() {
required
/>
</div>
{/* Mobile-friendly content editor */}
<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">
<label className="block text-sm font-medium text-gray-700 mb-2">Inhalt (Markdown)</label>
<textarea
value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm sm:text-base"
style={{ height: '240px' }}
rows={10}
required
/>
<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" dangerouslySetInnerHTML={{ __html: previewHtml }} />
<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>

View File

@@ -110,6 +110,46 @@ export async function GET(request: Request) {
});
}
}
const reinterpretAll = searchParams.get('reinterpretAll');
if (reinterpretAll === '1') {
// Call the Rust backend to force reinterpret all posts
const rustResult = spawnSync(
process.cwd() + '/markdown_backend/target/release/markdown_backend',
['reinterpret-all'],
{ encoding: 'utf-8' }
);
if (rustResult.status === 0 && rustResult.stdout) {
return new Response(rustResult.stdout, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} else {
return new Response(JSON.stringify({ error: rustResult.stderr || rustResult.error }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
const reparsePost = searchParams.get('reparsePost');
if (reparsePost) {
// Call the Rust backend to reparse a specific post
const rustResult = spawnSync(
process.cwd() + '/markdown_backend/target/release/markdown_backend',
['reparse-post', reparsePost],
{ encoding: 'utf-8' }
);
if (rustResult.status === 0 && rustResult.stdout) {
return new Response(rustResult.stdout, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} else {
return new Response(JSON.stringify({ error: rustResult.stderr || rustResult.error }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Return the current pinned.json object
try {
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');

View File

@@ -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);
@@ -34,3 +41,19 @@ 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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -5,8 +5,7 @@ import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import createDOMPurify from 'isomorphic-dompurify';
import hljs from 'highlight.js';
import { getPostsDirectory } from '@/lib/postsDirectory';
@@ -73,6 +72,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;
@@ -102,10 +105,8 @@ async function getPostByPath(filePath: string, relPath: string, pinnedData: { pi
let processedContent = '';
try {
const rawHtml = marked.parse(content);
const window = new JSDOM('').window;
const purify = DOMPurify(window);
processedContent = purify.sanitize(rawHtml as string, {
const rawHtml = marked.parse(content) as string;
processedContent = createDOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
@@ -119,7 +120,7 @@ async function getPostByPath(filePath: string, relPath: string, pinnedData: { pi
'src', 'alt', 'title', 'width', 'height',
'frameborder', 'allowfullscreen'
],
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+\.\-]+(?:[^a-z+\.\-:]|$))/i
});
} catch (err) {
console.error(`Error processing markdown for ${relPath}:`, err);

1
src/app/monaco-vim.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'monaco-vim';

View File

@@ -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() {
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
<div className="flex flex-wrap gap-1 sm:gap-2">
{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() {
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
<div className="flex flex-wrap gap-1 sm:gap-2">
{post.tags.map((tag: string) => (
<span