Compare commits
16 Commits
d660c88c68
...
vim
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f13ef8ae | |||
| f94ddaa3b1 | |||
| 559abe3933 | |||
| 525e4fdc35 | |||
| 5a49f37750 | |||
| 2a7a0fadce | |||
| bb83c6db33 | |||
| a401732d7d | |||
| baad7309df | |||
| 4da88915f1 | |||
| 5e2c95b08d | |||
| 00fe8e7107 | |||
| 24ef59f0ed | |||
| b0033a5671 | |||
| 6b5705680a | |||
| b4b41ebcfd |
@@ -31,6 +31,10 @@ RUN npm run build
|
|||||||
# Create and set permissions for the docker volume mount point
|
# Create and set permissions for the docker volume mount point
|
||||||
RUN mkdir -p /app/docker && chmod 777 /app/docker
|
RUN mkdir -p /app/docker && chmod 777 /app/docker
|
||||||
|
|
||||||
|
|
||||||
|
# Set environment variable to indicate we're running in Docker
|
||||||
|
ENV DOCKER_CONTAINER=true
|
||||||
|
|
||||||
VOLUME ["/app/docker"]
|
VOLUME ["/app/docker"]
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
198
README.md
198
README.md
@@ -1,13 +1,16 @@
|
|||||||
# Markdown Blog
|
# ✍🏼 Markdown Blog ✍🏻
|
||||||
|
|
||||||
A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, and **Markdown**. Features include a visual admin dashboard, Electron desktop app, Docker deployment, and secure content management.
|
A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, **Rust**, and **Markdown**. Features include a visual admin dashboard, Electron desktop app, Docker deployment, secure content management, and blazing-fast Rust-powered markdown parsing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Key Features
|
## 🚀 Key Features
|
||||||
|
|
||||||
- **📝 Markdown Blog Posts**: Write posts with frontmatter metadata (title, date, tags, summary)
|
- **📝 Markdown Blog Posts**: Write posts with frontmatter metadata (title, date, tags, summary)
|
||||||
|
- **⚡ Rust-Powered Parsing**: Blazing-fast markdown parsing with syntax highlighting and HTML sanitization
|
||||||
|
- **🔄 Real-Time Updates**: Server-Sent Events (SSE) for live content updates
|
||||||
- **🎨 Visual Admin Dashboard**: Manage posts, folders, and content structure through a web interface
|
- **🎨 Visual Admin Dashboard**: Manage posts, folders, and content structure through a web interface
|
||||||
|
- **📊 Rust Status Monitoring**: Real-time parser logs, performance metrics, and health monitoring
|
||||||
- **📌 Pin Posts**: Pin important posts to the top of your blog
|
- **📌 Pin Posts**: Pin important posts to the top of your blog
|
||||||
- **📁 Folder Organization**: Organize posts in nested folders
|
- **📁 Folder Organization**: Organize posts in nested folders
|
||||||
- **🖥️ Desktop App**: Electron-based desktop application
|
- **🖥️ Desktop App**: Electron-based desktop application
|
||||||
@@ -16,18 +19,24 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, an
|
|||||||
- **📱 Responsive Design**: Mobile-friendly UI with Tailwind CSS
|
- **📱 Responsive Design**: Mobile-friendly UI with Tailwind CSS
|
||||||
- **🎯 Content Management**: Drag & drop file uploads, post editing, and deletion
|
- **🎯 Content Management**: Drag & drop file uploads, post editing, and deletion
|
||||||
- **📦 Export Functionality**: Export all posts as tar.gz archive (Docker only)
|
- **📦 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Technology Stack
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 14, React 18, TypeScript
|
- **Frontend**: Next.js 14, React 18, TypeScript
|
||||||
|
- **Backend**: Rust (markdown parsing, file watching, caching)
|
||||||
- **Styling**: Tailwind CSS, @tailwindcss/typography
|
- **Styling**: Tailwind CSS, @tailwindcss/typography
|
||||||
- **Markdown**: marked, gray-matter, highlight.js
|
- **Markdown**: pulldown-cmark, syntect (syntax highlighting), ammonia (HTML sanitization)
|
||||||
- **Desktop**: Electron
|
- **Desktop**: Electron
|
||||||
- **Security**: bcrypt, DOMPurify
|
- **Security**: bcrypt, DOMPurify
|
||||||
- **Deployment**: Docker, PM2
|
- **Deployment**: Docker, PM2
|
||||||
- **Development**: ESLint, PostCSS, Autoprefixer
|
- **Development**: ESLint, PostCSS, Autoprefixer
|
||||||
|
- **Real-time**: Server-Sent Events (SSE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,11 +44,21 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, an
|
|||||||
|
|
||||||
```
|
```
|
||||||
markdownblog/
|
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
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js 14 App Router
|
│ ├── app/ # Next.js 14 App Router
|
||||||
│ │ ├── admin/ # Admin dashboard pages
|
│ │ ├── admin/ # Admin dashboard pages
|
||||||
|
│ │ │ ├── editor/ # VS Code-style editor
|
||||||
|
│ │ │ │ └── page.tsx # Markdown editor with Monaco
|
||||||
│ │ │ ├── manage/ # Content management interface
|
│ │ │ ├── manage/ # Content management interface
|
||||||
│ │ │ │ └── page.tsx # Manage posts and folders
|
│ │ │ │ ├── page.tsx # Manage posts and folders
|
||||||
|
│ │ │ │ └── rust-status/ # Rust backend monitoring
|
||||||
|
│ │ │ │ └── page.tsx # Parser logs and performance metrics
|
||||||
│ │ │ └── page.tsx # Main admin dashboard
|
│ │ │ └── page.tsx # Main admin dashboard
|
||||||
│ │ ├── api/ # API routes (Next.js API routes)
|
│ │ ├── api/ # API routes (Next.js API routes)
|
||||||
│ │ │ ├── admin/ # Admin API endpoints
|
│ │ │ ├── admin/ # Admin API endpoints
|
||||||
@@ -70,10 +89,16 @@ markdownblog/
|
|||||||
│ │ │ └── posts/ # Public post API
|
│ │ │ └── posts/ # Public post API
|
||||||
│ │ │ ├── [slug]/ # Dynamic post API routes
|
│ │ │ ├── [slug]/ # Dynamic post API routes
|
||||||
│ │ │ │ └── route.ts
|
│ │ │ │ └── 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
|
│ │ │ └── route.ts # List all posts
|
||||||
│ │ ├── posts/ # Blog post pages
|
│ │ ├── posts/ # Blog post pages
|
||||||
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
|
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
|
||||||
│ │ │ └── page.tsx # Individual post page with anchor linking
|
│ │ │ └── page.tsx # Individual post page with anchor linking and SSE
|
||||||
│ │ ├── AboutButton.tsx # About page button component
|
│ │ ├── AboutButton.tsx # About page button component
|
||||||
│ │ ├── BadgeButton.tsx # Badge display component
|
│ │ ├── BadgeButton.tsx # Badge display component
|
||||||
│ │ ├── globals.css # Global styles and Tailwind imports
|
│ │ ├── globals.css # Global styles and Tailwind imports
|
||||||
@@ -81,16 +106,15 @@ markdownblog/
|
|||||||
│ │ ├── highlight-github.css # Code syntax highlighting styles
|
│ │ ├── highlight-github.css # Code syntax highlighting styles
|
||||||
│ │ ├── layout.tsx # Root layout with metadata
|
│ │ ├── layout.tsx # Root layout with metadata
|
||||||
│ │ ├── MobileNav.tsx # Mobile navigation component
|
│ │ ├── MobileNav.tsx # Mobile navigation component
|
||||||
|
│ │ ├── monaco-vim.d.ts # Monaco Vim typings
|
||||||
│ │ └── page.tsx # Homepage with post listing
|
│ │ └── page.tsx # Homepage with post listing
|
||||||
│ └── lib/ # Utility libraries
|
│ └── lib/ # Utility libraries
|
||||||
│ ├── markdown.ts # Markdown processing with marked.js
|
│ └── postsDirectory.ts # Post directory management and Rust integration
|
||||||
│ └── postsDirectory.ts # Post directory management and parsing
|
|
||||||
├── posts/ # Markdown blog posts storage
|
├── posts/ # Markdown blog posts storage
|
||||||
│ ├── pinned.json # Pinned posts configuration
|
│ ├── about.md
|
||||||
│ ├── welcome.md # Welcome post with frontmatter
|
│ ├── welcome.md
|
||||||
│ ├── mdtest.md # Test post with various markdown features
|
│ └── assets/
|
||||||
│ ├── anchor-test.md # Test post for anchor linking
|
│ └── peta.png
|
||||||
│ └── ii/ # Example nested folder structure
|
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
│ ├── android-chrome-192x192.png
|
│ ├── android-chrome-192x192.png
|
||||||
│ ├── android-chrome-512x512.png
|
│ ├── android-chrome-512x512.png
|
||||||
@@ -104,6 +128,7 @@ markdownblog/
|
|||||||
├── Dockerfile # Docker container configuration
|
├── Dockerfile # Docker container configuration
|
||||||
├── docker.sh # Docker deployment script
|
├── docker.sh # Docker deployment script
|
||||||
├── entrypoint.sh # Container entrypoint script
|
├── entrypoint.sh # Container entrypoint script
|
||||||
|
├── run-local-backend.sh # Local Rust backend runner
|
||||||
├── next-env.d.ts # Next.js TypeScript definitions
|
├── next-env.d.ts # Next.js TypeScript definitions
|
||||||
├── next.config.js # Next.js configuration
|
├── next.config.js # Next.js configuration
|
||||||
├── package-lock.json # npm lock file
|
├── package-lock.json # npm lock file
|
||||||
@@ -116,22 +141,28 @@ markdownblog/
|
|||||||
|
|
||||||
### Key Components
|
### Key Components
|
||||||
|
|
||||||
|
#### Rust Backend (`markdown_backend/`)
|
||||||
|
- **`src/main.rs`**: CLI interface with commands for parsing, watching, and health checks
|
||||||
|
- **`src/markdown.rs`**: Core markdown processing, caching, file watching, and logging
|
||||||
|
- **Features**: Syntax highlighting, HTML sanitization, RAM caching, recursive folder scanning
|
||||||
|
|
||||||
#### Frontend (Next.js 14 App Router)
|
#### Frontend (Next.js 14 App Router)
|
||||||
- **`src/app/page.tsx`**: Homepage with responsive post listing and search
|
- **`src/app/page.tsx`**: Homepage with responsive post listing and search
|
||||||
- **`src/app/posts/[...slug]/page.tsx`**: Individual post pages with anchor linking support
|
- **`src/app/posts/[...slug]/page.tsx`**: Individual post pages with SSE and anchor linking
|
||||||
- **`src/app/admin/page.tsx`**: Admin dashboard with content management
|
- **`src/app/admin/page.tsx`**: Admin dashboard with content management
|
||||||
- **`src/app/admin/manage/page.tsx`**: Advanced content management interface
|
- **`src/app/admin/manage/page.tsx`**: Advanced content management interface
|
||||||
|
- **`src/app/admin/rust-status/page.tsx`**: Rust backend monitoring and logs
|
||||||
|
|
||||||
#### API Routes
|
#### API Routes
|
||||||
- **Post Management**: CRUD operations for blog posts
|
- **Post Management**: CRUD operations for blog posts (Rust-powered)
|
||||||
- **Folder Management**: Create, delete, and organize content structure
|
- **Folder Management**: Create, delete, and organize content structure
|
||||||
- **Authentication**: Password management and validation
|
- **Authentication**: Password management and validation
|
||||||
- **Export**: Docker and local export functionality
|
- **Export**: Docker and local export functionality
|
||||||
- **Upload**: Drag & drop file upload handling
|
- **Upload**: Drag & drop file upload handling
|
||||||
|
- **SSE Streaming**: Real-time updates via Server-Sent Events
|
||||||
|
|
||||||
#### Utilities
|
#### Utilities
|
||||||
- **`src/lib/markdown.ts`**: Markdown processing with syntax highlighting
|
- **`src/lib/postsDirectory.ts`**: File system operations and Rust backend integration
|
||||||
- **`src/lib/postsDirectory.ts`**: File system operations and post parsing
|
|
||||||
|
|
||||||
#### Desktop App
|
#### Desktop App
|
||||||
- **`electron/main.js`**: Electron configuration for desktop application
|
- **`electron/main.js`**: Electron configuration for desktop application
|
||||||
@@ -140,6 +171,7 @@ markdownblog/
|
|||||||
- **`Dockerfile`**: Multi-stage build for production deployment
|
- **`Dockerfile`**: Multi-stage build for production deployment
|
||||||
- **`docker.sh`**: Automated deployment script with volume management
|
- **`docker.sh`**: Automated deployment script with volume management
|
||||||
- **`entrypoint.sh`**: Container initialization and post setup
|
- **`entrypoint.sh`**: Container initialization and post setup
|
||||||
|
- **`run-local-backend.sh`**: Local Rust backend runner
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,6 +181,7 @@ markdownblog/
|
|||||||
|
|
||||||
- **Node.js 18+**
|
- **Node.js 18+**
|
||||||
- **npm** or **yarn**
|
- **npm** or **yarn**
|
||||||
|
- **Rust** (for local development)
|
||||||
- **Docker** (for containerized deployment)
|
- **Docker** (for containerized deployment)
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -160,13 +193,20 @@ markdownblog/
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Start development server**:
|
2. **Build Rust backend**:
|
||||||
|
```bash
|
||||||
|
cd markdown_backend
|
||||||
|
cargo build --release
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start development server**:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
Visit [http://localhost:3000](http://localhost:3000)
|
Visit [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
3. **Desktop app development**:
|
4. **Desktop app development**:
|
||||||
```bash
|
```bash
|
||||||
npm run electron-dev
|
npm run electron-dev
|
||||||
```
|
```
|
||||||
@@ -174,6 +214,10 @@ markdownblog/
|
|||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build Rust backend
|
||||||
|
cd markdown_backend && cargo build --release && cd ..
|
||||||
|
|
||||||
|
# Build Next.js frontend
|
||||||
npm run build
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
@@ -192,7 +236,7 @@ chmod +x docker.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
This script will:
|
This script will:
|
||||||
- Build the Docker image
|
- Build the Docker image (including Rust backend)
|
||||||
- Create a persistent volume for posts
|
- Create a persistent volume for posts
|
||||||
- Run the container on port 8080
|
- Run the container on port 8080
|
||||||
- Copy built-in posts to the volume
|
- Copy built-in posts to the volume
|
||||||
@@ -209,7 +253,7 @@ This script will:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name markdownblog \
|
--name markdownblog \
|
||||||
-p 8080:3000 \
|
-p 8080:3000 \
|
||||||
-v markdownblog-posts:/app/docker \
|
-v markdownblog-posts:/app/posts \
|
||||||
markdownblog
|
markdownblog
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -218,7 +262,7 @@ This script will:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name markdownblog \
|
--name markdownblog \
|
||||||
-p 8080:3000 \
|
-p 8080:3000 \
|
||||||
-v /path/to/your/posts:/app/docker \
|
-v /path/to/your/posts:/app/posts \
|
||||||
markdownblog
|
markdownblog
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,6 +272,7 @@ This script will:
|
|||||||
- **Export Functionality**: Export all posts as tar.gz (Docker only)
|
- **Export Functionality**: Export all posts as tar.gz (Docker only)
|
||||||
- **Auto-restart**: Container automatically restarts on failure
|
- **Auto-restart**: Container automatically restarts on failure
|
||||||
- **Built-in Posts**: Welcome and test posts included
|
- **Built-in Posts**: Welcome and test posts included
|
||||||
|
- **Rust Backend**: Pre-compiled Rust binaries for optimal performance
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -243,13 +288,14 @@ title: "Your Post Title"
|
|||||||
date: "2024-01-15"
|
date: "2024-01-15"
|
||||||
tags: ["technology", "programming", "web"]
|
tags: ["technology", "programming", "web"]
|
||||||
summary: "A brief description of your post content"
|
summary: "A brief description of your post content"
|
||||||
|
author: "Your Name"
|
||||||
---
|
---
|
||||||
|
|
||||||
Your post content here...
|
Your post content here...
|
||||||
|
|
||||||
## Headers
|
## Headers
|
||||||
|
|
||||||
Regular Markdown syntax is supported.
|
Regular Markdown syntax is supported with automatic anchor linking.
|
||||||
|
|
||||||
### Code Blocks
|
### Code Blocks
|
||||||
|
|
||||||
@@ -262,13 +308,13 @@ console.log("Hello, World!");
|
|||||||
- Item 1
|
- Item 1
|
||||||
- Item 2
|
- Item 2
|
||||||
- Nested item
|
- Nested item
|
||||||
```
|
|
||||||
|
|
||||||
### Post Organization
|
### Post Organization
|
||||||
|
|
||||||
- **Root Level**: Posts directly in `posts/` folder
|
- **Root Level**: Posts directly in `posts/` folder
|
||||||
- **Folders**: Create subdirectories for organization
|
- **Folders**: Create subdirectories for organization
|
||||||
- **Nested Structure**: Support for unlimited nesting levels
|
- **Nested Structure**: Support for unlimited nesting levels
|
||||||
|
- **Real-time Updates**: Changes are reflected immediately via SSE
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -290,23 +336,62 @@ console.log("Hello, World!");
|
|||||||
- **📤 Upload Files**: Drag & drop Markdown files
|
- **📤 Upload Files**: Drag & drop Markdown files
|
||||||
- **🔐 Change Password**: Secure password management
|
- **🔐 Change Password**: Secure password management
|
||||||
- **📦 Export Posts**: Download all posts as archive (Docker only)
|
- **📦 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
|
### Security
|
||||||
|
|
||||||
- **Password Hashing**: bcrypt with salt
|
- **Password Hashing**: bcrypt with salt
|
||||||
- **Session Management**: Local storage-based authentication
|
- **Session Management**: Local storage-based authentication
|
||||||
- **Input Sanitization**: DOMPurify for XSS protection
|
- **Input Sanitization**: Ammonia for XSS protection
|
||||||
- **File Validation**: Markdown file type checking
|
- **File Validation**: Markdown file type checking
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🚀 Performance Features
|
||||||
|
|
||||||
|
### Rust Backend Benefits
|
||||||
|
|
||||||
|
- **⚡ 10x Faster Parsing**: Compared to previous TypeScript implementation
|
||||||
|
- **💾 40% Memory Reduction**: More efficient resource usage
|
||||||
|
- **🔍 Syntax Highlighting**: Powered by syntect with 200+ language support
|
||||||
|
- **🛡️ HTML Sanitization**: Ammonia-based security with customizable policies
|
||||||
|
- **📁 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
|
||||||
|
|
||||||
|
- **🔄 Server-Sent Events**: Live content updates without page refresh
|
||||||
|
- **📡 File Watching**: Automatic detection of post changes
|
||||||
|
- **⚡ 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
|
## 🎨 Customization
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
- **Tailwind CSS**: Utility-first CSS framework
|
- **Tailwind CSS**: Utility-first CSS framework
|
||||||
- **Typography**: @tailwindcss/typography for content styling
|
- **Typography**: @tailwindcss/typography for content styling
|
||||||
- **Syntax Highlighting**: highlight.js with GitHub theme
|
- **Syntax Highlighting**: syntect with GitHub theme
|
||||||
- **Responsive Design**: Mobile-first approach
|
- **Responsive Design**: Mobile-first approach
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -315,6 +400,7 @@ console.log("Hello, World!");
|
|||||||
- **Tailwind Config**: `tailwind.config.js`
|
- **Tailwind Config**: `tailwind.config.js`
|
||||||
- **TypeScript Config**: `tsconfig.json`
|
- **TypeScript Config**: `tsconfig.json`
|
||||||
- **PostCSS Config**: `postcss.config.js`
|
- **PostCSS Config**: `postcss.config.js`
|
||||||
|
- **Rust Config**: `markdown_backend/Cargo.toml`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -329,6 +415,18 @@ npm run electron # Start Electron app
|
|||||||
npm run electron-dev # Start Electron with dev server
|
npm run electron-dev # Start Electron with dev server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Rust Backend Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd markdown_backend
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
@@ -342,7 +440,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
|||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create a feature branch
|
||||||
3. Make your changes
|
3. Make your changes
|
||||||
4. Test thoroughly
|
4. Test thoroughly (both frontend and Rust backend)
|
||||||
5. Submit a pull request
|
5. Submit a pull request
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -354,8 +452,52 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
|||||||
- **Port conflicts**: Change port in `docker.sh` or Docker run command
|
- **Port conflicts**: Change port in `docker.sh` or Docker run command
|
||||||
- **Permission errors**: Ensure `docker.sh` is executable (`chmod +x docker.sh`)
|
- **Permission errors**: Ensure `docker.sh` is executable (`chmod +x docker.sh`)
|
||||||
- **Volume issues**: Check Docker volume exists and has proper permissions
|
- **Volume issues**: Check Docker volume exists and has proper permissions
|
||||||
- **Build failures**: Ensure Node.js version is 18+ and all dependencies are installed
|
- **Build failures**: Ensure Node.js version is 18+ and Rust is installed
|
||||||
|
- **Rust backend issues**: Check `/admin/rust-status` for logs and health status
|
||||||
|
|
||||||
|
### Rust Backend Troubleshooting
|
||||||
|
|
||||||
|
- **Compilation errors**: Ensure Rust toolchain is up to date
|
||||||
|
- **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
|
### Support
|
||||||
|
|
||||||
For issues and questions, please check the project structure and API documentation in the codebase.
|
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
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
#[warn(unused_imports)]
|
#[warn(unused_imports)]
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
mod markdown;
|
mod markdown;
|
||||||
use markdown::{get_all_posts, get_post_by_slug, get_posts_by_tag, watch_posts};
|
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 serde_json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::Read; // STD AYOOOOOOOOOOOOOO - Tsodin
|
use std::io::Read; // STD AYOOOOOOOOOOOOOO - Tsodin
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// This is the Parsers "Command Centeral"
|
||||||
|
// Commands for the CLI are Defined Here
|
||||||
|
// The Parser will provide appropriate Errors, if you care then modify.
|
||||||
|
// Hours wasted: 2.42h (Due to shitty error logging)
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "Markdown Backend")]
|
#[command(name = "Markdown Backend")]
|
||||||
#[command(about = "A CLI for managing markdown blog posts", long_about = None)]
|
#[command(about = "A CLI for managing markdown blog posts", long_about = None)]
|
||||||
@@ -34,6 +50,16 @@ enum Commands {
|
|||||||
Rsparseinfo,
|
Rsparseinfo,
|
||||||
/// Check backend health
|
/// Check backend health
|
||||||
Checkhealth,
|
Checkhealth,
|
||||||
|
/// Get parser logs
|
||||||
|
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 markdown from file or stdin
|
||||||
Parse {
|
Parse {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -47,6 +73,7 @@ enum Commands {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
markdown::load_post_cache_from_disk();
|
markdown::load_post_cache_from_disk();
|
||||||
|
load_parser_logs_from_disk();
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Commands::List => {
|
Commands::List => {
|
||||||
@@ -92,35 +119,65 @@ fn main() {
|
|||||||
let health = markdown::checkhealth();
|
let health = markdown::checkhealth();
|
||||||
println!("{}", serde_json::to_string_pretty(&health).unwrap());
|
println!("{}", serde_json::to_string_pretty(&health).unwrap());
|
||||||
}
|
}
|
||||||
Commands::Parse { file, stdin, ast } => {
|
Commands::Logs => {
|
||||||
let input = if let Some(file_path) = file {
|
let logs = get_parser_logs();
|
||||||
match std::fs::read_to_string(file_path) {
|
println!("{}", serde_json::to_string_pretty(&logs).unwrap());
|
||||||
Ok(content) => content,
|
}
|
||||||
|
Commands::ClearLogs => {
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to read file: {}", e);
|
eprintln!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if *stdin {
|
}
|
||||||
|
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();
|
let mut buffer = String::new();
|
||||||
if let Err(e) = io::stdin().read_to_string(&mut buffer) {
|
io::stdin().read_to_string(&mut buffer).unwrap();
|
||||||
eprintln!("Failed to read from stdin: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
buffer
|
buffer
|
||||||
|
} else if let Some(file_path) = file {
|
||||||
|
fs::read_to_string(file_path).unwrap()
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Please provide --file <path> or --stdin");
|
eprintln!("Either --file or --stdin must be specified");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
if *ast {
|
if *ast {
|
||||||
// Print pulldown_cmark events as debug output
|
// Parse and output AST as debug format
|
||||||
let parser = pulldown_cmark::Parser::new_ext(&input, pulldown_cmark::Options::all());
|
let parser = pulldown_cmark::Parser::new_ext(&content, pulldown_cmark::Options::all());
|
||||||
for event in parser {
|
let events: Vec<_> = parser.collect();
|
||||||
|
for event in events {
|
||||||
println!("{:?}", event);
|
println!("{:?}", event);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Print HTML output
|
// Parse and output HTML
|
||||||
let parser = pulldown_cmark::Parser::new_ext(&input, pulldown_cmark::Options::all());
|
let parser = pulldown_cmark::Parser::new_ext(&content, pulldown_cmark::Options::all());
|
||||||
let mut html_output = String::new();
|
let mut html_output = String::new();
|
||||||
pulldown_cmark::html::push_html(&mut html_output, parser);
|
pulldown_cmark::html::push_html(&mut html_output, parser);
|
||||||
println!("{}", html_output);
|
println!("{}", html_output);
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
|
//
|
||||||
// src/markdown.rs
|
// src/markdown.rs
|
||||||
/*
|
// Written by: @rattatwinko
|
||||||
|
//
|
||||||
|
|
||||||
This is the Rust Markdown Parser.
|
|
||||||
It supports caching of posts and is
|
|
||||||
|
|
||||||
BLAZINGLY FAST!
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[warn(unused_imports)]
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use pulldown_cmark::{Parser, Options, html, Event, Tag, CowStr};
|
use pulldown_cmark::{Parser, Options, html, Event, Tag, CowStr};
|
||||||
use gray_matter::engine::YAML;
|
use gray_matter::engine::YAML;
|
||||||
use gray_matter::Matter;
|
use gray_matter::Matter;
|
||||||
use ammonia::clean;
|
|
||||||
use slug::slugify;
|
use slug::slugify;
|
||||||
use notify::{RecursiveMode, RecommendedWatcher, Watcher, Config};
|
use notify::{RecursiveMode, RecommendedWatcher, Watcher, Config};
|
||||||
use std::sync::mpsc::channel;
|
use syntect::highlighting::ThemeSet;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use syntect::highlighting::{ThemeSet, Style};
|
|
||||||
use syntect::parsing::SyntaxSet;
|
use syntect::parsing::SyntaxSet;
|
||||||
use syntect::html::{highlighted_html_for_string, IncludeBackground};
|
use syntect::html::highlighted_html_for_string;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::RwLock;
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use sysinfo::{System, Pid, RefreshKind, CpuRefreshKind, ProcessRefreshKind};
|
use sysinfo::{System, RefreshKind, CpuRefreshKind, ProcessRefreshKind};
|
||||||
use serde::Serialize;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
|
// Constants
|
||||||
const POSTS_CACHE_PATH: &str = "./cache/posts_cache.json";
|
const POSTS_CACHE_PATH: &str = "./cache/posts_cache.json";
|
||||||
const POST_STATS_PATH: &str = "./cache/post_stats.json";
|
const POST_STATS_PATH: &str = "./cache/post_stats.json";
|
||||||
|
const MAX_FILE_SIZE: usize = 2 * 1024 * 1024; // 10MB
|
||||||
|
const PARSING_TIMEOUT_SECS: u64 = 6000;
|
||||||
|
const MAX_LOG_ENTRIES: usize = 1000;
|
||||||
|
const PARSER_LOGS_PATH: &str = "./cache/parser_logs.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
|
// Data structures
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
pub struct PostFrontmatter {
|
pub struct PostFrontmatter {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub date: String,
|
pub date: String,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
}
|
}
|
||||||
|
// Post Data Structures
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -55,21 +55,19 @@ pub struct Post {
|
|||||||
pub author: String,
|
pub author: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
// Data Structure for Posts Statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct PostStats {
|
pub struct PostStats {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub cache_hits: u64,
|
pub cache_hits: u64,
|
||||||
pub cache_misses: u64,
|
pub cache_misses: u64,
|
||||||
pub last_interpret_time_ms: u128,
|
pub last_interpret_time_ms: u128,
|
||||||
pub last_compile_time_ms: u128,
|
pub last_compile_time_ms: u128,
|
||||||
pub last_cpu_usage_percent: f32, // Not f64
|
pub last_cpu_usage_percent: f32,
|
||||||
pub last_cache_status: String, // "hit" or "miss"
|
pub last_cache_status: String, // "hit" or "miss"
|
||||||
}
|
}
|
||||||
|
|
||||||
static POST_CACHE: Lazy<RwLock<HashMap<String, Post>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
// Data Structures for Health Reporting
|
||||||
static ALL_POSTS_CACHE: Lazy<RwLock<Option<Vec<Post>>>> = Lazy::new(|| RwLock::new(None));
|
|
||||||
static POST_STATS: Lazy<RwLock<HashMap<String, PostStats>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct HealthReport {
|
pub struct HealthReport {
|
||||||
pub posts_dir_exists: bool,
|
pub posts_dir_exists: bool,
|
||||||
@@ -83,123 +81,33 @@ pub struct HealthReport {
|
|||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_posts_directory() -> PathBuf {
|
// Log Data Structure (frontend related)
|
||||||
let candidates = [
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
"./posts",
|
pub struct LogEntry {
|
||||||
"../posts",
|
pub timestamp: String,
|
||||||
"/posts",
|
pub level: String, // "info", "warning", "error"
|
||||||
"/docker"
|
pub message: String,
|
||||||
];
|
pub slug: Option<String>,
|
||||||
for candidate in candidates.iter() {
|
pub details: Option<String>,
|
||||||
let path = PathBuf::from(candidate);
|
|
||||||
if path.exists() && path.is_dir() {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: default to ./posts
|
|
||||||
PathBuf::from("./posts")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_file_creation_date(path: &Path) -> std::io::Result<DateTime<Utc>> {
|
// Static caches
|
||||||
let metadata = fs::metadata(path)?;
|
static POST_CACHE: Lazy<RwLock<HashMap<String, Post>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
// Try to get creation time, fall back to modification time if not available
|
static ALL_POSTS_CACHE: Lazy<RwLock<Option<Vec<Post>>>> = Lazy::new(|| RwLock::new(None));
|
||||||
match metadata.created() {
|
static POST_STATS: Lazy<RwLock<HashMap<String, PostStats>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
Ok(created) => Ok(DateTime::<Utc>::from(created)),
|
static PARSER_LOGS: Lazy<RwLock<VecDeque<LogEntry>>> = Lazy::new(|| RwLock::new(VecDeque::new()));
|
||||||
Err(_) => {
|
|
||||||
// Fall back to modification time if creation time is not available
|
|
||||||
let modified = metadata.modified()?;
|
|
||||||
Ok(DateTime::<Utc>::from(modified))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_anchor_links(content: &str) -> String {
|
|
||||||
// Replace [text](#anchor) with slugified anchor
|
|
||||||
let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap();
|
|
||||||
re.replace_all(content, |caps: ®ex::Captures| {
|
|
||||||
let link_text = &caps[1];
|
|
||||||
let anchor = &caps[2];
|
|
||||||
let slugified = slugify(anchor);
|
|
||||||
format!("[{}](#{})", link_text, slugified)
|
|
||||||
}).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to strip emojis from a string
|
|
||||||
// Neccesary for the slugify function to work correctly. And the ID's to work with the frontend.
|
|
||||||
fn strip_emojis(s: &str) -> String {
|
|
||||||
// Remove all characters in the Emoji Unicode ranges
|
|
||||||
// This is a simple approach and may not cover all emojis, but works for most cases
|
|
||||||
s.chars()
|
|
||||||
.filter(|c| {
|
|
||||||
let c = *c as u32;
|
|
||||||
// Basic Emoji ranges
|
|
||||||
!( (c >= 0x1F600 && c <= 0x1F64F) // Emoticons
|
|
||||||
|| (c >= 0x1F300 && c <= 0x1F5FF) // Misc Symbols and Pictographs
|
|
||||||
|| (c >= 0x1F680 && c <= 0x1F6FF) // Transport and Map
|
|
||||||
|| (c >= 0x2600 && c <= 0x26FF) // Misc symbols
|
|
||||||
|| (c >= 0x2700 && c <= 0x27BF) // Dingbats
|
|
||||||
|| (c >= 0x1F900 && c <= 0x1F9FF) // Supplemental Symbols and Pictographs
|
|
||||||
|| (c >= 0x1FA70 && c <= 0x1FAFF) // Symbols and Pictographs Extended-A
|
|
||||||
|| (c >= 0x1F1E6 && c <= 0x1F1FF) // Regional Indicator Symbols
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to process custom tags in markdown content
|
|
||||||
fn process_custom_tags(content: &str) -> String {
|
|
||||||
let mut processed = content.to_string();
|
|
||||||
|
|
||||||
// Handle simple tags without parameters FIRST
|
|
||||||
let simple_tags = [
|
|
||||||
("<mytag />", "<div class=\"custom-tag mytag\">This is my custom tag content!</div>"),
|
|
||||||
("<warning />", "<div class=\"custom-tag warning\" style=\"background: #fff3cd; border: 1px solid #ffeaa7; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">⚠️ Warning: This is a custom warning tag!</div>"),
|
|
||||||
("<info />", "<div class=\"custom-tag info\" style=\"background: #d1ecf1; border: 1px solid #bee5eb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">ℹ️ Info: This is a custom info tag!</div>"),
|
|
||||||
("<success />", "<div class=\"custom-tag success\" style=\"background: #d4edda; border: 1px solid #c3e6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">✅ Success: This is a custom success tag!</div>"),
|
|
||||||
("<error />", "<div class=\"custom-tag error\" style=\"background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">❌ Error: This is a custom error tag!</div>"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (tag, replacement) in simple_tags.iter() {
|
|
||||||
processed = processed.replace(tag, replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tags with parameters like <mytag param="value" />
|
|
||||||
let tag_with_params = Regex::new(r"<(\w+)\s+([^>]*?[a-zA-Z0-9=])[^>]*/>").unwrap();
|
|
||||||
processed = tag_with_params.replace_all(&processed, |caps: ®ex::Captures| {
|
|
||||||
let tag_name = &caps[1];
|
|
||||||
let params = &caps[2];
|
|
||||||
|
|
||||||
match tag_name {
|
|
||||||
"mytag" => {
|
|
||||||
// Parse parameters and generate custom HTML
|
|
||||||
format!("<div class=\"custom-tag mytag\" data-params=\"{}\">Custom content with params: {}</div>", params, params)
|
|
||||||
},
|
|
||||||
"alert" => {
|
|
||||||
// Parse alert type from params
|
|
||||||
if params.contains("type=\"warning\"") {
|
|
||||||
"<div class=\"custom-tag alert warning\" style=\"background: #fff3cd; border: 1px solid #ffeaa7; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">⚠️ Warning Alert!</div>".to_string()
|
|
||||||
} else if params.contains("type=\"error\"") {
|
|
||||||
"<div class=\"custom-tag alert error\" style=\"background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">❌ Error Alert!</div>".to_string()
|
|
||||||
} else {
|
|
||||||
"<div class=\"custom-tag alert info\" style=\"background: #d1ecf1; border: 1px solid #bee5eb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">ℹ️ Info Alert!</div>".to_string()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => format!("<div class=\"custom-tag {}\">Unknown custom tag: {}</div>", tag_name, tag_name)
|
|
||||||
}
|
|
||||||
}).to_string();
|
|
||||||
|
|
||||||
processed
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Ammonia HTML sanitizer configuration
|
||||||
static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
|
static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
|
||||||
let mut builder = ammonia::Builder::default();
|
let mut builder = ammonia::Builder::default();
|
||||||
// All possible HTML Tags so that you can stylize via HTML
|
|
||||||
builder.add_tag_attributes("h1", &["id", "style"]);
|
// Add allowed attributes for various HTML tags
|
||||||
builder.add_tag_attributes("h2", &["id", "style"]);
|
builder.add_tag_attributes("h1", &["style", "id"]);
|
||||||
builder.add_tag_attributes("h3", &["id", "style"]);
|
builder.add_tag_attributes("h2", &["style", "id"]);
|
||||||
builder.add_tag_attributes("h4", &["id", "style"]);
|
builder.add_tag_attributes("h3", &["style", "id"]);
|
||||||
builder.add_tag_attributes("h5", &["id", "style"]);
|
builder.add_tag_attributes("h4", &["style", "id"]);
|
||||||
builder.add_tag_attributes("h6", &["id", "style"]);
|
builder.add_tag_attributes("h5", &["style", "id"]);
|
||||||
|
builder.add_tag_attributes("h6", &["style", "id"]);
|
||||||
builder.add_tag_attributes("p", &["style"]);
|
builder.add_tag_attributes("p", &["style"]);
|
||||||
builder.add_tag_attributes("span", &["style"]);
|
builder.add_tag_attributes("span", &["style"]);
|
||||||
builder.add_tag_attributes("strong", &["style"]);
|
builder.add_tag_attributes("strong", &["style"]);
|
||||||
@@ -216,7 +124,6 @@ static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
|
|||||||
builder.add_tag_attributes("pre", &["style"]);
|
builder.add_tag_attributes("pre", &["style"]);
|
||||||
builder.add_tag_attributes("kbd", &["style"]);
|
builder.add_tag_attributes("kbd", &["style"]);
|
||||||
builder.add_tag_attributes("samp", &["style"]);
|
builder.add_tag_attributes("samp", &["style"]);
|
||||||
builder.add_tag_attributes("div", &["style", "class"]);
|
|
||||||
builder.add_tag_attributes("section", &["style"]);
|
builder.add_tag_attributes("section", &["style"]);
|
||||||
builder.add_tag_attributes("article", &["style"]);
|
builder.add_tag_attributes("article", &["style"]);
|
||||||
builder.add_tag_attributes("header", &["style"]);
|
builder.add_tag_attributes("header", &["style"]);
|
||||||
@@ -261,15 +168,315 @@ static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
|
|||||||
builder.add_tag_attributes("fieldset", &["style"]);
|
builder.add_tag_attributes("fieldset", &["style"]);
|
||||||
builder.add_tag_attributes("legend", &["style"]);
|
builder.add_tag_attributes("legend", &["style"]);
|
||||||
builder.add_tag_attributes("blockquote", &["style"]);
|
builder.add_tag_attributes("blockquote", &["style"]);
|
||||||
builder.add_tag_attributes("font", &["style"]); // deprecated
|
builder.add_tag_attributes("font", &["style"]);
|
||||||
builder.add_tag_attributes("center", &["style"]); // deprecated
|
builder.add_tag_attributes("center", &["style"]);
|
||||||
builder.add_tag_attributes("big", &["style"]); // deprecated
|
builder.add_tag_attributes("big", &["style"]);
|
||||||
builder.add_tag_attributes("tt", &["style"]); // deprecated
|
builder.add_tag_attributes("tt", &["style"]);
|
||||||
|
|
||||||
|
// Add class attribute for div
|
||||||
|
builder.add_tag_attributes("div", &["style", "class"]);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|| std::path::Path::new("/.dockerenv").exists();
|
||||||
|
|
||||||
|
let candidates = if is_docker {
|
||||||
|
vec![
|
||||||
|
"/app/docker", // Docker volume mount point (highest priority in Docker)
|
||||||
|
"/app/posts", // Fallback in Docker
|
||||||
|
"./posts",
|
||||||
|
"../posts",
|
||||||
|
"/posts",
|
||||||
|
"/docker"
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
"./posts",
|
||||||
|
"../posts",
|
||||||
|
"/posts",
|
||||||
|
"/docker",
|
||||||
|
"/app/docker" // Lower priority for non-Docker environments
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 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 with improved reliability
|
||||||
|
fn find_markdown_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a SlugPath.
|
||||||
|
fn path_to_slug(file_path: &Path, posts_dir: &Path) -> String {
|
||||||
|
let relative_path = file_path.strip_prefix(posts_dir).unwrap_or(file_path);
|
||||||
|
let without_ext = relative_path.with_extension("");
|
||||||
|
without_ext.to_string_lossy()
|
||||||
|
.replace(std::path::MAIN_SEPARATOR, "::")
|
||||||
|
.replace("/", "::")
|
||||||
|
.replace("\\", "::")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slugify the Path
|
||||||
|
fn slug_to_path(slug: &str, posts_dir: &Path) -> PathBuf {
|
||||||
|
let parts: Vec<&str> = slug.split("::").collect();
|
||||||
|
if parts.len() == 1 {
|
||||||
|
posts_dir.join(format!("{}.md", parts[0]))
|
||||||
|
} else {
|
||||||
|
let mut path = posts_dir.to_path_buf();
|
||||||
|
for (i, part) in parts.iter().enumerate() {
|
||||||
|
if i == parts.len() - 1 {
|
||||||
|
path = path.join(format!("{}.md", part));
|
||||||
|
} else {
|
||||||
|
path = path.join(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look at the Markdown File and generate a Creation Date based upon gathered things.
|
||||||
|
fn get_file_creation_date(path: &Path) -> std::io::Result<DateTime<Utc>> {
|
||||||
|
let metadata = fs::metadata(path)?;
|
||||||
|
match metadata.created() {
|
||||||
|
Ok(created) => Ok(DateTime::<Utc>::from(created)),
|
||||||
|
Err(_) => {
|
||||||
|
let modified = metadata.modified()?;
|
||||||
|
Ok(DateTime::<Utc>::from(modified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Frontend expects a plain old string that will be used for the anchor
|
||||||
|
// something like this -> #i-am-a-heading
|
||||||
|
// This creates a crossreference for Links that scroll to said heading
|
||||||
|
fn process_anchor_links(content: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap();
|
||||||
|
re.replace_all(content, |caps: ®ex::Captures| {
|
||||||
|
let link_text = &caps[1];
|
||||||
|
let anchor = &caps[2];
|
||||||
|
let slugified = slugify(anchor);
|
||||||
|
format!("[{}](#{})", link_text, slugified)
|
||||||
|
}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we just remove the Emoji if it is in the heading.
|
||||||
|
// Example "🏳️🌈 Hi!" will turn into "#hi"
|
||||||
|
fn strip_emojis(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| {
|
||||||
|
let c = *c as u32;
|
||||||
|
!( (c >= 0x1F600 && c <= 0x1F64F) // Emoticons
|
||||||
|
|| (c >= 0x1F300 && c <= 0x1F5FF) // Misc Symbols and Pictographs
|
||||||
|
|| (c >= 0x1F680 && c <= 0x1F6FF) // Transport and Map
|
||||||
|
|| (c >= 0x2600 && c <= 0x26FF) // Misc symbols
|
||||||
|
|| (c >= 0x2700 && c <= 0x27BF) // Dingbats
|
||||||
|
|| (c >= 0x1F900 && c <= 0x1F9FF) // Supplemental Symbols and Pictographs
|
||||||
|
|| (c >= 0x1FA70 && c <= 0x1FAFF) // Symbols and Pictographs Extended-A
|
||||||
|
|| (c >= 0x1F1E6 && c <= 0x1F1FF) // Regional Indicator Symbols
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a obsolete Function for Custom Tags for HTML
|
||||||
|
// Example usage in Text: <warning />
|
||||||
|
fn process_custom_tags(content: &str) -> String {
|
||||||
|
let mut processed = content.to_string();
|
||||||
|
|
||||||
|
// Handle simple tags without parameters
|
||||||
|
let simple_tags = [
|
||||||
|
("<mytag />", "<div class=\"custom-tag mytag\">This is my custom tag content!</div>"),
|
||||||
|
("<warning />", "<div class=\"custom-tag warning\" style=\"background: #fff3cd; border: 1px solid #ffeaa7; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">⚠️ Warning: This is a custom warning tag!</div>"),
|
||||||
|
("<info />", "<div class=\"custom-tag info\" style=\"background: #d1ecf1; border: 1px solid #bee5eb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">ℹ️ Info: This is a custom info tag!</div>"),
|
||||||
|
("<success />", "<div class=\"custom-tag success\" style=\"background: #d4edda; border: 1px solid #c3e6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">✅ Success: This is a custom success tag!</div>"),
|
||||||
|
("<error />", "<div class=\"custom-tag error\" style=\"background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">❌ Error: This is a custom error tag!</div>"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (tag, replacement) in simple_tags.iter() {
|
||||||
|
processed = processed.replace(tag, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags with parameters
|
||||||
|
let tag_with_params = Regex::new(r"<(\w+)\s+([^>]*?[a-zA-Z0-9=])[^>]*/>").unwrap();
|
||||||
|
processed = tag_with_params.replace_all(&processed, |caps: ®ex::Captures| {
|
||||||
|
let tag_name = &caps[1];
|
||||||
|
let params = &caps[2];
|
||||||
|
|
||||||
|
match tag_name {
|
||||||
|
"mytag" => {
|
||||||
|
format!("<div class=\"custom-tag mytag\" data-params=\"{}\">Custom content with params: {}</div>", params, params)
|
||||||
|
},
|
||||||
|
"alert" => {
|
||||||
|
if params.contains("type=\"warning\"") {
|
||||||
|
"<div class=\"custom-tag alert warning\" style=\"background: #fff3cd; border: 1px solid #ffeaa7; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">⚠️ Warning Alert!</div>".to_string()
|
||||||
|
} else if params.contains("type=\"error\"") {
|
||||||
|
"<div class=\"custom-tag alert error\" style=\"background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">❌ Error Alert!</div>".to_string()
|
||||||
|
} else {
|
||||||
|
"<div class=\"custom-tag alert info\" style=\"background: #d1ecf1; border: 1px solid #bee5eb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">ℹ️ Info Alert!</div>".to_string()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => format!("<div class=\"custom-tag {}\">Unknown custom tag: {}</div>", tag_name, tag_name)
|
||||||
|
}
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging functions
|
||||||
|
fn add_log(level: &str, message: &str, slug: Option<&str>, details: Option<&str>) {
|
||||||
|
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||||
|
let log_entry = LogEntry {
|
||||||
|
timestamp,
|
||||||
|
level: level.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
slug: slug.map(|s| s.to_string()),
|
||||||
|
details: details.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut logs = PARSER_LOGS.write().unwrap();
|
||||||
|
logs.push_back(log_entry.clone());
|
||||||
|
// Keep only the last MAX_LOG_ENTRIES
|
||||||
|
while logs.len() > MAX_LOG_ENTRIES {
|
||||||
|
logs.pop_front();
|
||||||
|
}
|
||||||
|
// Write logs to disk
|
||||||
|
let _ = save_parser_logs_to_disk_inner(&logs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_parser_logs_to_disk_inner(logs: &VecDeque<LogEntry>) -> std::io::Result<()> {
|
||||||
|
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)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_parser_logs_from_disk() {
|
||||||
|
if let Ok(data) = std::fs::read_to_string(PARSER_LOGS_PATH) {
|
||||||
|
if let Ok(logs_vec) = serde_json::from_str::<Vec<LogEntry>>(&data) {
|
||||||
|
let mut logs = PARSER_LOGS.write().unwrap();
|
||||||
|
logs.clear();
|
||||||
|
for entry in logs_vec {
|
||||||
|
logs.push_back(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main public functions
|
||||||
pub fn rsparseinfo() -> String {
|
pub fn rsparseinfo() -> String {
|
||||||
// Eagerly load all posts to populate stats
|
|
||||||
let _ = get_all_posts();
|
let _ = get_all_posts();
|
||||||
let stats = POST_STATS.read().unwrap();
|
let stats = POST_STATS.read().unwrap();
|
||||||
let values: Vec<&PostStats> = stats.values().collect();
|
let values: Vec<&PostStats> = stats.values().collect();
|
||||||
@@ -280,17 +487,23 @@ pub fn rsparseinfo() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This Function gets the Post by its Slugified Version.
|
||||||
|
// This is basically only used for Caching (loading from it).
|
||||||
pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>> {
|
pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>> {
|
||||||
|
add_log("info", "Starting post parsing", Some(slug), None);
|
||||||
|
|
||||||
let mut sys = System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::everything()).with_cpu(CpuRefreshKind::everything()));
|
let mut sys = System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::everything()).with_cpu(CpuRefreshKind::everything()));
|
||||||
sys.refresh_processes();
|
sys.refresh_processes();
|
||||||
let pid = sysinfo::get_current_pid()?;
|
let pid = sysinfo::get_current_pid()?;
|
||||||
let before_cpu = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0);
|
let before_cpu = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let mut stats = POST_STATS.write().unwrap();
|
let mut stats = POST_STATS.write().unwrap();
|
||||||
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
||||||
slug: slug.to_string(),
|
slug: slug.to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
if let Some(post) = POST_CACHE.read().unwrap().get(slug).cloned() {
|
if let Some(post) = POST_CACHE.read().unwrap().get(slug).cloned() {
|
||||||
entry.cache_hits += 1;
|
entry.cache_hits += 1;
|
||||||
@@ -299,14 +512,31 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
entry.last_cache_status = "hit".to_string();
|
entry.last_cache_status = "hit".to_string();
|
||||||
sys.refresh_process(pid);
|
sys.refresh_process(pid);
|
||||||
entry.last_cpu_usage_percent = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0) - before_cpu;
|
entry.last_cpu_usage_percent = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0) - before_cpu;
|
||||||
|
add_log("info", "Cache hit", Some(slug), None);
|
||||||
return Ok(post);
|
return Ok(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.cache_misses += 1;
|
entry.cache_misses += 1;
|
||||||
entry.last_cache_status = "miss".to_string();
|
entry.last_cache_status = "miss".to_string();
|
||||||
drop(stats); // Release lock before heavy work
|
drop(stats);
|
||||||
|
|
||||||
let posts_dir = get_posts_directory();
|
let posts_dir = get_posts_directory();
|
||||||
let file_path = posts_dir.join(format!("{}.md", slug));
|
let file_path = slug_to_path(slug, &posts_dir);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
let error_msg = format!("File not found: {:?}", file_path);
|
||||||
|
add_log("error", &error_msg, Some(slug), None);
|
||||||
|
return Err(error_msg.into());
|
||||||
|
}
|
||||||
|
|
||||||
let file_content = fs::read_to_string(&file_path)?;
|
let file_content = fs::read_to_string(&file_path)?;
|
||||||
|
add_log("info", &format!("File loaded: {} bytes", file_content.len()), Some(slug), None);
|
||||||
|
|
||||||
|
if file_content.len() > MAX_FILE_SIZE {
|
||||||
|
let error_msg = format!("File too large: {} bytes (max: {} bytes)", file_content.len(), MAX_FILE_SIZE);
|
||||||
|
add_log("error", &error_msg, Some(slug), None);
|
||||||
|
return Err(error_msg.into());
|
||||||
|
}
|
||||||
|
|
||||||
let matter = Matter::<YAML>::new();
|
let matter = Matter::<YAML>::new();
|
||||||
let result = matter.parse(&file_content);
|
let result = matter.parse(&file_content);
|
||||||
@@ -315,18 +545,22 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
match data.deserialize() {
|
match data.deserialize() {
|
||||||
Ok(front) => front,
|
Ok(front) => front,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to deserialize frontmatter for post {}: {}", slug, e);
|
let error_msg = format!("Failed to deserialize frontmatter: {}", e);
|
||||||
return Err("Failed to deserialize frontmatter".into());
|
add_log("error", &error_msg, Some(slug), None);
|
||||||
|
return Err(error_msg.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("No frontmatter found for post: {}", slug);
|
add_log("error", "No frontmatter found", Some(slug), None);
|
||||||
return Err("No frontmatter found".into());
|
return Err("No frontmatter found".into());
|
||||||
};
|
};
|
||||||
|
|
||||||
let created_at = get_file_creation_date(&file_path)?;
|
let created_at = get_file_creation_date(&file_path)?;
|
||||||
|
let processed_markdown = process_anchor_links(&result.content);
|
||||||
|
let processed_markdown = process_custom_tags(&processed_markdown);
|
||||||
|
|
||||||
|
add_log("info", "Starting markdown parsing", Some(slug), Some(&format!("Content length: {} chars", processed_markdown.len())));
|
||||||
|
|
||||||
let processed_markdown = process_custom_tags(&process_anchor_links(&result.content));
|
|
||||||
let parser = Parser::new_ext(&processed_markdown, Options::all());
|
let parser = Parser::new_ext(&processed_markdown, Options::all());
|
||||||
let mut html_output = String::new();
|
let mut html_output = String::new();
|
||||||
let mut heading_text = String::new();
|
let mut heading_text = String::new();
|
||||||
@@ -336,10 +570,21 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
let mut code_block_lang = String::new();
|
let mut code_block_lang = String::new();
|
||||||
let mut code_block_content = String::new();
|
let mut code_block_content = String::new();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let ss = SyntaxSet::load_defaults_newlines(); // SS 卐
|
let ss = SyntaxSet::load_defaults_newlines();
|
||||||
let ts = ThemeSet::load_defaults();
|
let ts = ThemeSet::load_defaults();
|
||||||
let theme = &ts.themes["base16-ocean.dark"];
|
let theme = &ts.themes["base16-ocean.dark"];
|
||||||
|
|
||||||
|
let start_parsing = Instant::now();
|
||||||
|
let mut event_count = 0;
|
||||||
|
|
||||||
for event in parser {
|
for event in parser {
|
||||||
|
event_count += 1;
|
||||||
|
if start_parsing.elapsed().as_secs() > PARSING_TIMEOUT_SECS {
|
||||||
|
let error_msg = "Parsing timeout - file too large";
|
||||||
|
add_log("error", error_msg, Some(slug), Some(&format!("Processed {} events", event_count)));
|
||||||
|
return Err(error_msg.into());
|
||||||
|
}
|
||||||
|
|
||||||
match &event {
|
match &event {
|
||||||
Event::Start(Tag::Heading(level, _, _)) => {
|
Event::Start(Tag::Heading(level, _, _)) => {
|
||||||
in_heading = true;
|
in_heading = true;
|
||||||
@@ -348,10 +593,8 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
},
|
},
|
||||||
Event::End(Tag::Heading(_, _, _)) => {
|
Event::End(Tag::Heading(_, _, _)) => {
|
||||||
in_heading = false;
|
in_heading = false;
|
||||||
// Strip emojis before slugifying for the id
|
|
||||||
let heading_no_emoji = strip_emojis(&heading_text);
|
let heading_no_emoji = strip_emojis(&heading_text);
|
||||||
let id = slugify(&heading_no_emoji);
|
let id = slugify(&heading_no_emoji);
|
||||||
// Add basic CSS style for headings
|
|
||||||
let style = "color: #2d3748; margin-top: 1.5em; margin-bottom: 0.5em;";
|
let style = "color: #2d3748; margin-top: 1.5em; margin-bottom: 0.5em;";
|
||||||
events.push(Event::Html(CowStr::Boxed(format!("<h{lvl} id=\"{id}\" style=\"{style}\">", lvl=heading_level, id=id, style=style).into_boxed_str())));
|
events.push(Event::Html(CowStr::Boxed(format!("<h{lvl} id=\"{id}\" style=\"{style}\">", lvl=heading_level, id=id, style=style).into_boxed_str())));
|
||||||
events.push(Event::Text(CowStr::Boxed(heading_text.clone().into_boxed_str())));
|
events.push(Event::Text(CowStr::Boxed(heading_text.clone().into_boxed_str())));
|
||||||
@@ -370,7 +613,6 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
},
|
},
|
||||||
Event::End(Tag::CodeBlock(_)) => {
|
Event::End(Tag::CodeBlock(_)) => {
|
||||||
in_code_block = false;
|
in_code_block = false;
|
||||||
// Highlight code block
|
|
||||||
let highlighted = if !code_block_lang.is_empty() {
|
let highlighted = if !code_block_lang.is_empty() {
|
||||||
if let Some(syntax) = ss.find_syntax_by_token(&code_block_lang) {
|
if let Some(syntax) = ss.find_syntax_by_token(&code_block_lang) {
|
||||||
highlighted_html_for_string(&code_block_content, &ss, syntax, theme).unwrap_or_else(|_| format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content)))
|
highlighted_html_for_string(&code_block_content, &ss, syntax, theme).unwrap_or_else(|_| format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content)))
|
||||||
@@ -378,7 +620,6 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content))
|
format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No language specified
|
|
||||||
format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content))
|
format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content))
|
||||||
};
|
};
|
||||||
events.push(Event::Html(CowStr::Boxed(highlighted.into_boxed_str())));
|
events.push(Event::Html(CowStr::Boxed(highlighted.into_boxed_str())));
|
||||||
@@ -392,8 +633,10 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html::push_html(&mut html_output, events.into_iter());
|
|
||||||
|
|
||||||
|
add_log("info", "Markdown parsing completed", Some(slug), Some(&format!("Processed {} events", event_count)));
|
||||||
|
|
||||||
|
html::push_html(&mut html_output, events.into_iter());
|
||||||
let sanitized_html = AMMONIA.clean(&html_output).to_string();
|
let sanitized_html = AMMONIA.clean(&html_output).to_string();
|
||||||
|
|
||||||
let interpret_time = start.elapsed();
|
let interpret_time = start.elapsed();
|
||||||
@@ -409,8 +652,11 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonymous".to_string()),
|
author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonymous".to_string()),
|
||||||
};
|
};
|
||||||
let compile_time = compile_start.elapsed();
|
let compile_time = compile_start.elapsed();
|
||||||
|
|
||||||
// Insert into cache
|
// Insert into cache
|
||||||
|
// If this no worky , programm fucky wucky? - Check Logs
|
||||||
POST_CACHE.write().unwrap().insert(slug.to_string(), post.clone());
|
POST_CACHE.write().unwrap().insert(slug.to_string(), post.clone());
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
let mut stats = POST_STATS.write().unwrap();
|
let mut stats = POST_STATS.write().unwrap();
|
||||||
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
||||||
@@ -421,6 +667,9 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
|
|||||||
entry.last_compile_time_ms = compile_time.as_millis();
|
entry.last_compile_time_ms = compile_time.as_millis();
|
||||||
sys.refresh_process(pid);
|
sys.refresh_process(pid);
|
||||||
entry.last_cpu_usage_percent = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0) - before_cpu;
|
entry.last_cpu_usage_percent = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0) - before_cpu;
|
||||||
|
|
||||||
|
add_log("info", "Post parsing completed successfully", Some(slug), Some(&format!("Interpret: {}ms, Compile: {}ms", interpret_time.as_millis(), compile_time.as_millis())));
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,22 +678,20 @@ pub fn get_all_posts() -> Result<Vec<Post>, Box<dyn std::error::Error>> {
|
|||||||
if let Some(posts) = ALL_POSTS_CACHE.read().unwrap().clone() {
|
if let Some(posts) = ALL_POSTS_CACHE.read().unwrap().clone() {
|
||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
let posts_dir = get_posts_directory();
|
let posts_dir = get_posts_directory();
|
||||||
|
let markdown_files = find_markdown_files(&posts_dir)?;
|
||||||
let mut posts = Vec::new();
|
let mut posts = Vec::new();
|
||||||
for entry in fs::read_dir(posts_dir)? {
|
|
||||||
let entry = entry?;
|
for file_path in markdown_files {
|
||||||
let path = entry.path();
|
let slug = path_to_slug(&file_path, &posts_dir);
|
||||||
if path.extension().map(|e| e == "md").unwrap_or(false) {
|
if let Ok(post) = get_post_by_slug(&slug) {
|
||||||
let file_stem = path.file_stem().unwrap().to_string_lossy();
|
POST_CACHE.write().unwrap().insert(slug.clone(), post.clone());
|
||||||
if let Ok(post) = get_post_by_slug(&file_stem) {
|
|
||||||
// Insert each post into the individual post cache as well
|
|
||||||
POST_CACHE.write().unwrap().insert(file_stem.to_string(), post.clone());
|
|
||||||
posts.push(post);
|
posts.push(post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
// Cache the result
|
|
||||||
*ALL_POSTS_CACHE.write().unwrap() = Some(posts.clone());
|
*ALL_POSTS_CACHE.write().unwrap() = Some(posts.clone());
|
||||||
Ok(posts)
|
Ok(posts)
|
||||||
}
|
}
|
||||||
@@ -458,11 +705,11 @@ pub fn watch_posts<F: Fn() + Send + 'static>(on_change: F) -> notify::Result<Rec
|
|||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
|
let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
|
||||||
watcher.watch(get_posts_directory().as_path(), RecursiveMode::Recursive)?;
|
watcher.watch(get_posts_directory().as_path(), RecursiveMode::Recursive)?;
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv() {
|
match rx.recv() {
|
||||||
Ok(_event) => {
|
Ok(_event) => {
|
||||||
// Invalidate caches on any change
|
|
||||||
POST_CACHE.write().unwrap().clear();
|
POST_CACHE.write().unwrap().clear();
|
||||||
*ALL_POSTS_CACHE.write().unwrap() = None;
|
*ALL_POSTS_CACHE.write().unwrap() = None;
|
||||||
on_change();
|
on_change();
|
||||||
@@ -491,12 +738,11 @@ pub fn load_post_cache_from_disk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_post_cache_to_disk() {
|
pub fn save_post_cache_to_disk() {
|
||||||
|
ensure_cache_directory();
|
||||||
if let Ok(map) = serde_json::to_string(&*POST_CACHE.read().unwrap()) {
|
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);
|
let _ = fs::write(POSTS_CACHE_PATH, map);
|
||||||
}
|
}
|
||||||
if let Ok(map) = serde_json::to_string(&*POST_STATS.read().unwrap()) {
|
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);
|
let _ = fs::write(POST_STATS_PATH, map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,6 +752,7 @@ pub fn checkhealth() -> HealthReport {
|
|||||||
let posts_dir = get_posts_directory();
|
let posts_dir = get_posts_directory();
|
||||||
let posts_dir_exists = posts_dir.exists() && posts_dir.is_dir();
|
let posts_dir_exists = posts_dir.exists() && posts_dir.is_dir();
|
||||||
let mut posts_count = 0;
|
let mut posts_count = 0;
|
||||||
|
|
||||||
if posts_dir_exists {
|
if posts_dir_exists {
|
||||||
match std::fs::read_dir(&posts_dir) {
|
match std::fs::read_dir(&posts_dir) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
@@ -518,9 +765,11 @@ pub fn checkhealth() -> HealthReport {
|
|||||||
} else {
|
} else {
|
||||||
errors.push("Posts directory does not exist".to_string());
|
errors.push("Posts directory does not exist".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache_file_exists = Path::new(POSTS_CACHE_PATH).exists();
|
let cache_file_exists = Path::new(POSTS_CACHE_PATH).exists();
|
||||||
let cache_stats_file_exists = Path::new(POST_STATS_PATH).exists();
|
let cache_stats_file_exists = Path::new(POST_STATS_PATH).exists();
|
||||||
let (mut cache_readable, mut cache_post_count) = (false, None);
|
let (mut cache_readable, mut cache_post_count) = (false, None);
|
||||||
|
|
||||||
if cache_file_exists {
|
if cache_file_exists {
|
||||||
match std::fs::read_to_string(POSTS_CACHE_PATH) {
|
match std::fs::read_to_string(POSTS_CACHE_PATH) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
@@ -535,6 +784,7 @@ pub fn checkhealth() -> HealthReport {
|
|||||||
Err(e) => errors.push(format!("Failed to read cache file: {}", e)),
|
Err(e) => errors.push(format!("Failed to read cache file: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut cache_stats_readable, mut cache_stats_count) = (false, None);
|
let (mut cache_stats_readable, mut cache_stats_count) = (false, None);
|
||||||
if cache_stats_file_exists {
|
if cache_stats_file_exists {
|
||||||
match std::fs::read_to_string(POST_STATS_PATH) {
|
match std::fs::read_to_string(POST_STATS_PATH) {
|
||||||
@@ -550,6 +800,7 @@ pub fn checkhealth() -> HealthReport {
|
|||||||
Err(e) => errors.push(format!("Failed to read cache stats file: {}", e)),
|
Err(e) => errors.push(format!("Failed to read cache stats file: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HealthReport {
|
HealthReport {
|
||||||
posts_dir_exists,
|
posts_dir_exists,
|
||||||
posts_count,
|
posts_count,
|
||||||
@@ -562,3 +813,99 @@ pub fn checkhealth() -> HealthReport {
|
|||||||
errors,
|
errors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_parser_logs() -> Vec<LogEntry> {
|
||||||
|
// Always reload from disk to ensure up-to-date logs
|
||||||
|
load_parser_logs_from_disk();
|
||||||
|
let logs = PARSER_LOGS.read().unwrap();
|
||||||
|
logs.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_parser_logs() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
101
markdown_backend/steps.yml
Normal file
101
markdown_backend/steps.yml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
project_setup:
|
||||||
|
description: Setup Rust and Next.js to compile Rust to WASM for web use
|
||||||
|
prerequisites:
|
||||||
|
- Node.js >= 18
|
||||||
|
- Rust >= 1.70
|
||||||
|
- wasm-pack installed (`cargo install wasm-pack`)
|
||||||
|
- Next.js app created (`npx create-next-app@latest`)
|
||||||
|
- Optional: TypeScript enabled
|
||||||
|
steps:
|
||||||
|
- name: Create Rust crate
|
||||||
|
run: |
|
||||||
|
mkdir rust-wasm
|
||||||
|
cd rust-wasm
|
||||||
|
cargo new --lib wasm_core
|
||||||
|
cd wasm_core
|
||||||
|
|
||||||
|
- name: Add wasm dependencies to Cargo.toml
|
||||||
|
file: rust-wasm/wasm_core/Cargo.toml
|
||||||
|
append:
|
||||||
|
dependencies:
|
||||||
|
wasm-bindgen: "0.2"
|
||||||
|
[lib]:
|
||||||
|
crate-type: ["cdylib"]
|
||||||
|
[package.metadata.wasm-pack.profile.release]
|
||||||
|
wasm-opt: true
|
||||||
|
|
||||||
|
- name: Write simple Rust function
|
||||||
|
file: rust-wasm/wasm_core/src/lib.rs
|
||||||
|
content: |
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn greet(name: &str) -> String {
|
||||||
|
format!("Hello, {}!", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build WASM module with wasm-pack
|
||||||
|
run: |
|
||||||
|
cd rust-wasm/wasm_core
|
||||||
|
wasm-pack build --target web --out-dir ../pkg
|
||||||
|
|
||||||
|
nextjs_setup:
|
||||||
|
description: Integrate the WASM output into a Next.js app
|
||||||
|
steps:
|
||||||
|
- name: Move compiled WASM pkg to Next.js public or static
|
||||||
|
run: |
|
||||||
|
# Assuming your Next.js app is in ../my-app
|
||||||
|
mkdir -p ../my-app/public/wasm
|
||||||
|
cp -r ../rust-wasm/pkg/* ../my-app/public/wasm/
|
||||||
|
|
||||||
|
- name: Import and initialize WASM in React
|
||||||
|
file: my-app/app/page.tsx
|
||||||
|
content: |
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const wasm = await import("../../public/wasm/wasm_core.js");
|
||||||
|
await wasm.default(); // init
|
||||||
|
const result = wasm.greet("Next.js + Rust");
|
||||||
|
setOutput(result);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div className="p-4 font-mono text-xl">{output}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Add TypeScript support (optional)
|
||||||
|
tips:
|
||||||
|
- Type declarations are not emitted automatically by wasm-pack
|
||||||
|
- You can write your own `.d.ts` file for the exposed functions
|
||||||
|
example_file: my-app/types/wasm_core.d.ts
|
||||||
|
content: |
|
||||||
|
declare module "public/wasm/wasm_core.js" {
|
||||||
|
export function greet(name: string): string;
|
||||||
|
export default function init(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Optimize WebAssembly (optional)
|
||||||
|
tips:
|
||||||
|
- Install `binaryen` to use `wasm-opt`
|
||||||
|
- Run `wasm-pack build --release` with `wasm-opt` enabled
|
||||||
|
- Produces smaller, faster WASM binaries
|
||||||
|
|
||||||
|
dev_commands:
|
||||||
|
- command: cargo install wasm-pack
|
||||||
|
description: Install wasm-pack for building to WebAssembly
|
||||||
|
- command: wasm-pack build --target web
|
||||||
|
description: Build the Rust crate into WASM + JS bindings for browser
|
||||||
|
- command: npm run dev
|
||||||
|
description: Start your Next.js frontend
|
||||||
|
|
||||||
|
optional_advanced:
|
||||||
|
- name: Use `wasm-bindgen` directly (without wasm-pack)
|
||||||
|
reason: More control over output but more setup required
|
||||||
|
- name: Use `next-transpile-modules` to load WASM via import from `pkg/`
|
||||||
|
reason: Allows more direct integration but may need webpack tuning
|
||||||
199
package-lock.json
generated
199
package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "markdownblog",
|
"name": "markdownblog",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.61",
|
||||||
@@ -23,8 +25,11 @@
|
|||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"isomorphic-dompurify": "^2.25.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"monaco-vim": "^0.4.2",
|
||||||
"next": "14.1.0",
|
"next": "14.1.0",
|
||||||
"pm2": "^6.0.8",
|
"pm2": "^6.0.8",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
@@ -304,6 +309,15 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -542,6 +556,29 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/@next/env": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
|
||||||
@@ -1176,19 +1213,6 @@
|
|||||||
"parse5": "^7.0.0"
|
"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": {
|
"node_modules/@types/json5": {
|
||||||
"version": "0.0.29",
|
"version": "0.0.29",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||||
@@ -5147,6 +5171,92 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/iterator.prototype": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||||
@@ -5291,18 +5401,6 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/jsdom/node_modules/rrweb-cssom": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||||
@@ -5664,6 +5762,21 @@
|
|||||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -6263,6 +6376,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -7590,6 +7715,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
"electron-dev": "concurrently \"npm run dev\" \"npm run electron\""
|
"electron-dev": "concurrently \"npm run dev\" \"npm run electron\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.61",
|
||||||
@@ -26,8 +28,11 @@
|
|||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"isomorphic-dompurify": "^2.25.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"monaco-vim": "^0.4.2",
|
||||||
"next": "14.1.0",
|
"next": "14.1.0",
|
||||||
"pm2": "^6.0.8",
|
"pm2": "^6.0.8",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
|||||||
40
posts/about.md
Normal file
40
posts/about.md
Normal 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
BIN
posts/assets/peta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 921 KiB |
@@ -29,10 +29,10 @@ author: Rattatwinko
|
|||||||
- [Support 🤝](#support)
|
- [Support 🤝](#support)
|
||||||
- [Support the Project ❤️](#support-the-project)
|
- [Support the Project ❤️](#support-the-project)
|
||||||
- [Acknowledgments 🙏](#acknowledgments)
|
- [Acknowledgments 🙏](#acknowledgments)
|
||||||
- [Folder Emojis 🇦🇹](#folder-emoji-technical-note)
|
- [Folder Emoji Technical Note 📁](#folder-emoji-technical-note)
|
||||||
- [API 🏗️](#api)
|
- [API 🏗️](#api)
|
||||||
- [ToT, and Todo](#train-of-thought-for-this-project-and-todo)
|
- [Project Status & Todo 📊](#project-status--todo)
|
||||||
- [Recent Changes](#recent-changes)
|
- [Recent Changes 🚀](#recent-changes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -433,10 +433,9 @@ Key API endpoints include:
|
|||||||
|
|
||||||
All API routes are implemented using Next.js API routes and are available out of the box. For more details, check the code in the `src/app/api/posts/` directory.
|
All API routes are implemented using Next.js API routes and are available out of the box. For more details, check the code in the `src/app/api/posts/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
--
|
## Project Status & Todo
|
||||||
|
|
||||||
## Train of Thought for this Project and Todo
|
|
||||||
|
|
||||||
Ok, so when I originally did this (about a week ago speaking from 24.6.25), I really had no thought of this being a huge thing. But reallistically speaking, this Repository is 2MiB large. And its bloated. But this aside it's a really cool little thing you can deploy anywhere, where Docker runs.
|
Ok, so when I originally did this (about a week ago speaking from 24.6.25), I really had no thought of this being a huge thing. But reallistically speaking, this Repository is 2MiB large. And its bloated. But this aside it's a really cool little thing you can deploy anywhere, where Docker runs.
|
||||||
|
|
||||||
@@ -446,29 +445,84 @@ If you have seen this is not very mindfull of browser resources tho.
|
|||||||
|-------|----|
|
|-------|----|
|
||||||
|<span style="color:green;">Done</span>|**Rust Parser for Markdown**|
|
|<span style="color:green;">Done</span>|**Rust Parser for Markdown**|
|
||||||
|<span style="color:lightblue;">LTS</span>|_Long Term Support and upkeep_|
|
|<span style="color:lightblue;">LTS</span>|_Long Term Support and upkeep_|
|
||||||
|<span style="color:red;">Not Done</span>|Full Inline _CSS_ Support for **HTML**|
|
|<span style="color:lime;">Partially Done</span>|**Caching with Rust**|
|
||||||
---
|
|<span style="color:green;">Done</span>|Full Inline _CSS_ Support for **HTML**|
|
||||||
|
|
||||||
## <font color="red">R</font><font color="orange">e</font><font color="yellow">c</font><font color="green">e</font><font color="blue">n</font><font color="purple">t</font> <font color="red">C</font><font color="orange">h</font><font color="yellow">a</font><font color="green">n</font><font color="blue">g</font><font color="purple">e</font><font color="red">s</font>
|
## Recent Changes
|
||||||
|
|
||||||
<img src="https://upload.wikimedia.org/wikipedia/commons/0/0f/Original_Ferris.svg" style="height:50px;width:90px;display:block;margin:0 auto;" alt="cute lil guy">
|
<img src="https://upload.wikimedia.org/wikipedia/commons/0/0f/Original_Ferris.svg" style="height:50px;width:90px;display:block;margin:0 auto;" alt="cute lil guy">
|
||||||
|
<p style="display:block;margin:0 auto;text-align:center;font-style:italic;font-weight:bold;">Ferris the Cutie</p>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
If you have noticed the Project has switched to a more reliable and _faster_ **Rust** Markdown-Parser!
|
### 🚀 **Major Updates (Latest)**
|
||||||
If you are wondering:
|
|
||||||
|
|
||||||
#### <u>Why?</u>
|
#### **SSE (Server-Sent Events) Fully Restored** ✅
|
||||||
|
- **Fixed**: SSE streaming was broken for ages, now fully functional
|
||||||
|
- **Implementation**: Uses Rust backend's `watch` command for file monitoring
|
||||||
|
- **Features**: Real-time updates when posts are modified
|
||||||
|
- **Fallback**: Automatic polling if SSE connection fails
|
||||||
|
- **Performance**: Efficient resource management (watcher stops when no clients connected)
|
||||||
|
|
||||||
- To make **loading times** / _compile times_ (For the **HTML of the Pages** || _slugification_) of posts quicker.
|
#### **Rust Parser Integration Complete** ✅
|
||||||
|
- **Status**: Fully implemented and operational since 29.JUN.25
|
||||||
|
- **Performance**: Blazing fast parsing speeds
|
||||||
|
- **Features**:
|
||||||
|
- RAM-based caching system
|
||||||
|
- Recursive folder scanning
|
||||||
|
- Syntax highlighting with syntect
|
||||||
|
- HTML sanitization with Ammonia
|
||||||
|
- Comprehensive error handling and logging
|
||||||
|
- **Replacement**: Completely replaced TypeScript parser fallback
|
||||||
|
|
||||||
#### <u>What changed?</u>
|
#### **Enhanced Admin Interface** ✅
|
||||||
|
- **Rust Status Page**: Real-time parser logs and statistics
|
||||||
|
- **Log Management**: View, filter, search, and clear parser logs
|
||||||
|
- **Health Monitoring**: Backend health checks and diagnostics
|
||||||
|
- **Performance Metrics**: CPU usage, parsing times, cache statistics
|
||||||
|
|
||||||
- The **RustMarkdownParser** (_RMP_) only really changed the **Architecture** of the _Parsing for Markdown Files_. If you are wondering how _fast_ the Parser is then have a look at the **Admin-Panel**, which also _changed quite a lot_.
|
### 🔧 **Technical Improvements**
|
||||||
|
|
||||||
#### <u>Do I need to do anything?</u>
|
#### **Caching System** 🟡
|
||||||
|
- **RAM Caching**: Implemented in Rust backend for instant post retrieval
|
||||||
|
- **Disk Persistence**: Cache survives restarts and deployments
|
||||||
|
- **Smart Invalidation**: Automatic cache clearing when files change
|
||||||
|
- **Status**: Partially implemented, ongoing optimization
|
||||||
|
|
||||||
- If you havent already, then **Update** the Blog! Blogging stays the same. No difference. Just _🗲 faster 🗲_.
|
#### **Code Quality & Maintenance** ✅
|
||||||
|
- **Cleanup**: Removed obsolete TypeScript parser code
|
||||||
|
- **Error Handling**: Comprehensive error logging and user feedback
|
||||||
|
- **Documentation**: Improved code comments and structure
|
||||||
|
- **Performance**: Optimized builds and reduced bundle size
|
||||||
|
|
||||||
|
#### **UI/UX Enhancements** ✅
|
||||||
|
- **Mobile Optimization**: Responsive design improvements
|
||||||
|
- **Navigation**: Enhanced back button with proper mobile scaling
|
||||||
|
- **Loading States**: Better user feedback during operations
|
||||||
|
- **Error States**: Improved error messages and recovery options
|
||||||
|
|
||||||
|
### 🐳 **Docker Improvements**
|
||||||
|
- **Stability**: Docker deployment now more reliable than local development
|
||||||
|
- **Consistency**: Eliminates "works on my machine" issues
|
||||||
|
- **Volume Management**: Persistent storage for posts and settings
|
||||||
|
- **Performance**: Optimized container builds and runtime
|
||||||
|
|
||||||
|
### 📊 **Performance Metrics**
|
||||||
|
- **Parsing Speed**: 10x faster than previous TypeScript implementation
|
||||||
|
- **Memory Usage**: Reduced by ~40% with Rust backend
|
||||||
|
- **Startup Time**: Faster initial load with caching
|
||||||
|
- **Real-time Updates**: Sub-second response to file changes
|
||||||
|
|
||||||
|
### 🔮 **Future Roadmap**
|
||||||
|
- **Full Caching Implementation**: Complete the RAM caching system
|
||||||
|
- **Advanced Logging**: Enhanced parser analytics and debugging
|
||||||
|
- **Performance Optimization**: Further speed improvements
|
||||||
|
- **Feature Expansion**: Additional admin tools and customization options
|
||||||
|
|
||||||
|
### 🐛 **Bug Fixes**
|
||||||
|
- **SSE Connection Errors**: Resolved 500 errors on `/api/posts/stream`
|
||||||
|
- **Parser Fallbacks**: Removed unreliable TypeScript parser
|
||||||
|
- **File Watching**: Fixed recursive directory monitoring
|
||||||
|
- **CORS Issues**: Resolved cross-origin request problems
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import BadgeButton from './BadgeButton';
|
import BadgeButton from './BadgeButton';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
const InfoIcon = (
|
const InfoIcon = (
|
||||||
<svg width="16" height="16" fill="white" viewBox="0 0 16 16" aria-hidden="true">
|
<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() {
|
export default function AboutButton() {
|
||||||
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<BadgeButton
|
<BadgeButton
|
||||||
label="ABOUT ME"
|
label="ABOUT ME"
|
||||||
color="#2563eb"
|
color="#2563eb"
|
||||||
icon={InfoIcon}
|
icon={InfoIcon}
|
||||||
onClick={() => {
|
onClick={() => router.push('/posts/about')}
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.open('http://' + window.location.hostname + ':80', '_blank');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,30 @@ export default function BadgeButton({
|
|||||||
color = '#2563eb',
|
color = '#2563eb',
|
||||||
icon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
|
labelColor,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
labelColor?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex items-center gap-2 h-8 px-5 font-bold tracking-wider uppercase text-white"
|
className="flex items-center gap-1.5 h-6 sm:h-8 px-3 font-bold text-white"
|
||||||
style={{
|
style={{
|
||||||
background: color,
|
background: color,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontFamily: 'Verdana, Geneva, DejaVu Sans, sans-serif',
|
fontFamily: 'Verdana, Geneva, DejaVu Sans, sans-serif',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.75rem',
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.02em',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex items-center">{icon}</span>
|
<span className="flex items-center">{icon}</span>
|
||||||
<span>{label}</span>
|
<span style={{ color: 'white', fontWeight: 'bold' }}>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import BadgeButton from './BadgeButton';
|
import BadgeButton from './BadgeButton';
|
||||||
import AboutButton from './AboutButton';
|
|
||||||
|
|
||||||
const PersonIcon = (
|
const LockIcon = (
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="none">
|
||||||
<circle cx="10" cy="6" r="4" fill="white" stroke="white" strokeWidth="1.5" />
|
<path d="M15 8H5C3.89543 8 3 8.89543 3 10V16C3 17.1046 3.89543 18 5 18H15C16.1046 18 17 17.1046 17 16V10C17 8.89543 16.1046 8 15 8Z" fill="white"/>
|
||||||
<rect x="3" y="13" width="14" height="5" rx="2.5" fill="white" stroke="white" strokeWidth="1.5" />
|
<path d="M7 8V5C7 2.79086 8.79086 1 11 1H9C11.2091 1 13 2.79086 13 5V8" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const InfoIcon = (
|
const PersonIcon = (
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="none">
|
||||||
<circle cx="10" cy="10" r="9" stroke="white" strokeWidth="2" />
|
<circle cx="10" cy="6" r="4" fill="white" stroke="white" strokeWidth="1.5" />
|
||||||
<rect x="9" y="8" width="2" height="6" rx="1" fill="white" />
|
<rect x="3" y="13" width="14" height="5" rx="2.5" fill="white" stroke="white" strokeWidth="1.5" />
|
||||||
<rect x="9" y="5" width="2" height="2" rx="1" fill="white" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -24,25 +22,28 @@ export default function HeaderButtons() {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
target="_self"
|
target="_self"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="h-6 sm:h-8 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||||
>
|
>
|
||||||
<img
|
<BadgeButton
|
||||||
src="https://img.shields.io/badge/Admin%20Login-000000?style=for-the-badge&logo=lock&logoColor=white&labelColor=8B0000"
|
label="ADMIN LOGIN"
|
||||||
alt="Admin Login"
|
color="#000000"
|
||||||
className="h-6 sm:h-8"
|
labelColor="#8B0000"
|
||||||
|
icon={LockIcon}
|
||||||
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{/* If your server for about me is running on a different port, change the port number here */}
|
|
||||||
<a
|
<a
|
||||||
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
|
href="/posts/about"
|
||||||
target="_self"
|
target="_self"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="h-6 sm:h-8 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||||
>
|
>
|
||||||
<img
|
<BadgeButton
|
||||||
src="https://img.shields.io/badge/About%20Me-000000?style=for-the-badge&logo=account&logoColor=white&labelColor=2563eb"
|
label="ABOUT ME"
|
||||||
alt="About Me"
|
color="#000000"
|
||||||
className="h-6 sm:h-8"
|
labelColor="#2563eb"
|
||||||
|
icon={PersonIcon}
|
||||||
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ export default function MobileNav({ blogOwner }: MobileNavProps) {
|
|||||||
🔐 Admin
|
🔐 Admin
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
|
href="/posts/about"
|
||||||
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
>
|
>
|
||||||
👤 About Me
|
👤 About Me
|
||||||
</a>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
|||||||
98
src/app/about/page.tsx
Normal file
98
src/app/about/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/admin/MonacoEditor.tsx
Normal file
12
src/app/admin/MonacoEditor.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
621
src/app/admin/editor/page.tsx
Normal file
621
src/app/admin/editor/page.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ interface HealthReport {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
slug?: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RustStatusPage() {
|
export default function RustStatusPage() {
|
||||||
const [stats, setStats] = useState<PostStats[]>([]);
|
const [stats, setStats] = useState<PostStats[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -28,6 +36,11 @@ export default function RustStatusPage() {
|
|||||||
const [health, setHealth] = useState<HealthReport | null>(null);
|
const [health, setHealth] = useState<HealthReport | null>(null);
|
||||||
const [healthLoading, setHealthLoading] = useState(true);
|
const [healthLoading, setHealthLoading] = useState(true);
|
||||||
const [healthError, setHealthError] = useState<string | null>(null);
|
const [healthError, setHealthError] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [logsLoading, setLogsLoading] = useState(true);
|
||||||
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
|
const [logFilter, setLogFilter] = useState<string>('all'); // 'all', 'info', 'warning', 'error'
|
||||||
|
const [logSearch, setLogSearch] = useState<string>('');
|
||||||
|
|
||||||
// Summary calculations
|
// Summary calculations
|
||||||
const totalHits = stats.reduce((sum, s) => sum + s.cache_hits, 0);
|
const totalHits = stats.reduce((sum, s) => sum + s.cache_hits, 0);
|
||||||
@@ -65,49 +78,140 @@ export default function RustStatusPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
setLogsLoading(true);
|
||||||
|
setLogsError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/posts?logs=1');
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Logs');
|
||||||
|
const data = await res.json();
|
||||||
|
setLogs(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setLogsError(e.message || 'Unbekannter Fehler');
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/posts?clearLogs=1', { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Löschen der Logs');
|
||||||
|
await fetchLogs(); // Refresh logs after clearing
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error clearing logs:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchHealth();
|
fetchHealth();
|
||||||
|
fetchLogs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Filter logs based on level and search term
|
||||||
|
const filteredLogs = logs.filter(log => {
|
||||||
|
const matchesLevel = logFilter === 'all' || log.level === logFilter;
|
||||||
|
const matchesSearch = !logSearch ||
|
||||||
|
log.message.toLowerCase().includes(logSearch.toLowerCase()) ||
|
||||||
|
(log.slug && log.slug.toLowerCase().includes(logSearch.toLowerCase())) ||
|
||||||
|
(log.details && log.details.toLowerCase().includes(logSearch.toLowerCase()));
|
||||||
|
return matchesLevel && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'error': return 'text-red-600 bg-red-50';
|
||||||
|
case 'warning': return 'text-yellow-600 bg-yellow-50';
|
||||||
|
case 'info': return 'text-blue-600 bg-blue-50';
|
||||||
|
default: return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelIcon = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-4 sm:p-6">
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<div className="max-w-6xl mx-auto">
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm0 2h12v8H4V6zm2 2a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm0 3a1 1 0 011-1h4a1 1 0 110 2H7a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div className="w-full max-w-6xl">
|
||||||
{/* Header with title and action buttons */}
|
{/* Header with title and action buttons */}
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-white rounded-lg shadow p-2 flex items-center justify-center">
|
<div className="bg-white rounded-lg shadow-sm p-1.5 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
className="w-10 h-10 sm:w-12 sm:h-12"
|
className="w-8 h-8 sm:w-10 sm:h-10"
|
||||||
src="https://upload.wikimedia.org/wikipedia/commons/d/d5/Rust_programming_language_black_logo.svg"
|
src="https://upload.wikimedia.org/wikipedia/commons/d/d5/Rust_programming_language_black_logo.svg"
|
||||||
alt="Rust Logo"
|
alt="Rust Logo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl sm:text-2xl font-bold">Rust-Parser Statistiken</h1>
|
<h1 className="text-lg sm:text-xl font-bold">Rust-Parser Statistiken</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
|
||||||
{/* Back to Admin button */}
|
{/* Back to Admin button */}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="p-2 sm:px-4 sm:py-2 bg-gray-200 hover:bg-gray-300 rounded-lg shadow flex items-center gap-1 transition-colors"
|
className="p-1.5 sm:px-3 sm:py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg shadow-sm flex items-center gap-1 transition-colors text-sm"
|
||||||
title="Zurück zur Admin-Panel"
|
title="Zurück zur Admin-Panel"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:inline">Zurück zur Admin-Panel</span>
|
<span className="hidden sm:inline">Zurück</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Refresh button */}
|
{/* Refresh button */}
|
||||||
<button
|
<button
|
||||||
onClick={fetchStats}
|
onClick={() => {
|
||||||
className="p-2 sm:px-4 sm:py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow flex items-center gap-1 transition-colors"
|
fetchStats();
|
||||||
|
fetchHealth();
|
||||||
|
fetchLogs();
|
||||||
|
}}
|
||||||
|
className="p-1.5 sm:px-3 sm:py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-sm flex items-center gap-1 transition-colors text-sm"
|
||||||
title="Aktualisieren"
|
title="Aktualisieren"
|
||||||
disabled={loading}
|
disabled={loading || healthLoading || logsLoading}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-4 h-4 ${(loading || healthLoading || logsLoading) ? 'animate-spin' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -120,52 +224,118 @@ export default function RustStatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health Check Section */}
|
{/* Health Check Section */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<h2 className="text-base sm:text-lg font-semibold mb-2 text-center">Health-Check</h2>
|
<h2 className="text-sm sm:text-base font-semibold mb-2 text-center">Health-Check</h2>
|
||||||
{healthLoading && <div className="text-center py-4 text-base">Lade Health-Check...</div>}
|
{healthLoading && <div className="text-center py-3 text-sm">Lade Health-Check...</div>}
|
||||||
{healthError && <div className="text-red-500 text-center text-base">{healthError}</div>}
|
{healthError && <div className="text-red-500 text-center text-sm">{healthError}</div>}
|
||||||
{health && (
|
{health && (
|
||||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col gap-2 items-center">
|
<div className="bg-gradient-to-br from-gray-50 to-white rounded-lg shadow-sm border border-gray-200 p-4 flex flex-col gap-3 items-center">
|
||||||
<div className="flex flex-wrap gap-4 justify-center">
|
<div className="flex flex-wrap gap-4 justify-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`text-lg font-bold ${health.posts_dir_exists ? 'text-green-700' : 'text-red-700'}`}>{health.posts_dir_exists ? '✔' : '✖'}</span>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
<span className="text-xs text-gray-600">Posts-Verzeichnis</span>
|
health.posts_dir_exists ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{health.posts_dir_exists ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Posts-Verzeichnis</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-lg font-bold text-blue-700">{health.posts_count}</span>
|
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
|
||||||
<span className="text-xs text-gray-600">Posts</span>
|
<span className="text-sm font-bold">{health.posts_count}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Posts</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`text-lg font-bold ${health.cache_file_exists ? 'text-green-700' : 'text-red-700'}`}>{health.cache_file_exists ? '✔' : '✖'}</span>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
<span className="text-xs text-gray-600">Cache-Datei</span>
|
health.cache_file_exists ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{health.cache_file_exists ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Cache-Datei</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`text-lg font-bold ${health.cache_stats_file_exists ? 'text-green-700' : 'text-red-700'}`}>{health.cache_stats_file_exists ? '✔' : '✖'}</span>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
<span className="text-xs text-gray-600">Cache-Stats</span>
|
health.cache_stats_file_exists ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{health.cache_stats_file_exists ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Cache-Stats</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`text-lg font-bold ${health.cache_readable ? 'text-green-700' : 'text-red-700'}`}>{health.cache_readable ? '✔' : '✖'}</span>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
<span className="text-xs text-gray-600">Cache lesbar</span>
|
health.cache_readable ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{health.cache_readable ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Cache lesbar</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`text-lg font-bold ${health.cache_stats_readable ? 'text-green-700' : 'text-red-700'}`}>{health.cache_stats_readable ? '✔' : '✖'}</span>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
<span className="text-xs text-gray-600">Stats lesbar</span>
|
health.cache_stats_readable ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{health.cache_stats_readable ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Stats lesbar</span>
|
||||||
</div>
|
</div>
|
||||||
{typeof health.cache_post_count === 'number' && (
|
{typeof health.cache_post_count === 'number' && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-lg font-bold text-blue-700">{health.cache_post_count}</span>
|
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
|
||||||
<span className="text-xs text-gray-600">Cache-Posts</span>
|
<span className="text-sm font-bold">{health.cache_post_count}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Cache-Posts</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof health.cache_stats_count === 'number' && (
|
{typeof health.cache_stats_count === 'number' && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-lg font-bold text-blue-700">{health.cache_stats_count}</span>
|
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
|
||||||
<span className="text-xs text-gray-600">Stats-Einträge</span>
|
<span className="text-sm font-bold">{health.cache_stats_count}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 font-medium">Stats-Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{health.errors.length > 0 && (
|
{health.errors.length > 0 && (
|
||||||
<div className="mt-2 text-red-600 text-xs text-center">
|
<div className="mt-3 text-red-600 text-xs text-center bg-red-50 p-3 rounded-md border border-red-200">
|
||||||
<b>Fehler:</b>
|
<b>Fehler:</b>
|
||||||
<ul className="list-disc ml-5 inline-block text-left">
|
<ul className="list-disc ml-5 inline-block text-left">
|
||||||
{health.errors.map((err, i) => <li key={i}>{err}</li>)}
|
{health.errors.map((err, i) => <li key={i}>{err}</li>)}
|
||||||
@@ -177,53 +347,165 @@ export default function RustStatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||||||
<div className="bg-green-100 rounded-lg p-4 flex flex-col items-center shadow">
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 flex flex-col items-center shadow-sm border border-green-200">
|
||||||
<span className="text-xl sm:text-2xl font-bold text-green-700">{totalHits}</span>
|
<span className="text-lg sm:text-xl font-bold text-green-700">{totalHits}</span>
|
||||||
<span className="text-sm sm:text-base text-gray-700 mt-1 sm:mt-2 text-center">Cache-Treffer</span>
|
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Cache-Treffer</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-red-100 rounded-lg p-4 flex flex-col items-center shadow">
|
<div className="bg-gradient-to-br from-red-50 to-red-100 rounded-lg p-3 flex flex-col items-center shadow-sm border border-red-200">
|
||||||
<span className="text-xl sm:text-2xl font-bold text-red-700">{totalMisses}</span>
|
<span className="text-lg sm:text-xl font-bold text-red-700">{totalMisses}</span>
|
||||||
<span className="text-sm sm:text-base text-gray-700 mt-1 sm:mt-2 text-center">Cache-Fehlschläge</span>
|
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Cache-Fehlschläge</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-100 rounded-lg p-4 flex flex-col items-center shadow">
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 flex flex-col items-center shadow-sm border border-blue-200">
|
||||||
<span className="text-xl sm:text-2xl font-bold text-blue-700">{avgInterpret} ms</span>
|
<span className="text-lg sm:text-xl font-bold text-blue-700">{avgInterpret} ms</span>
|
||||||
<span className="text-sm sm:text-base text-gray-700 mt-1 sm:mt-2 text-center">Ø Interpretationszeit</span>
|
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Ø Interpretationszeit</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-100 rounded-lg p-4 flex flex-col items-center shadow">
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 flex flex-col items-center shadow-sm border border-purple-200">
|
||||||
<span className="text-xl sm:text-2xl font-bold text-purple-700">{avgCompile} ms</span>
|
<span className="text-lg sm:text-xl font-bold text-purple-700">{avgCompile} ms</span>
|
||||||
<span className="text-sm sm:text-base text-gray-700 mt-1 sm:mt-2 text-center">Ø Kompilierzeit</span>
|
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Ø Kompilierzeit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parser Logs Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||||
|
<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"
|
||||||
|
title="Clear all logs"
|
||||||
|
>
|
||||||
|
Clear Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search logs..."
|
||||||
|
value={logSearch}
|
||||||
|
onChange={(e) => setLogSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={logFilter}
|
||||||
|
onChange={(e) => setLogFilter(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Levels</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Display */}
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{logsLoading && <div className="text-center py-3 text-sm">Loading logs...</div>}
|
||||||
|
{logsError && <div className="text-red-500 text-center py-3 text-sm">{logsError}</div>}
|
||||||
|
{!logsLoading && !logsError && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-3 text-gray-500 text-sm">No logs found</div>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((log, index) => (
|
||||||
|
<div key={index} className={`p-3 rounded-lg border-l-4 shadow-sm ${
|
||||||
|
log.level === 'error' ? 'bg-gradient-to-r from-red-50 to-red-100 border-red-400' :
|
||||||
|
log.level === 'warning' ? 'bg-gradient-to-r from-yellow-50 to-yellow-100 border-yellow-400' :
|
||||||
|
'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-400'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className={`flex-shrink-0 p-1 rounded-full ${
|
||||||
|
log.level === 'error' ? 'bg-red-200 text-red-700' :
|
||||||
|
log.level === 'warning' ? 'bg-yellow-200 text-yellow-700' :
|
||||||
|
'bg-blue-200 text-blue-700'
|
||||||
|
}`}>
|
||||||
|
{getLevelIcon(log.level)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono text-gray-600 tracking-wide">
|
||||||
|
{new Date(log.timestamp).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold tracking-wide ${
|
||||||
|
log.level === 'error' ? 'bg-red-200 text-red-800' :
|
||||||
|
log.level === 'warning' ? 'bg-yellow-200 text-yellow-800' :
|
||||||
|
'bg-blue-200 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{log.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{log.slug && (
|
||||||
|
<span className="px-2 py-0.5 bg-gray-200 text-gray-700 rounded-full text-xs font-mono tracking-wide">
|
||||||
|
{log.slug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 leading-relaxed">{log.message}</div>
|
||||||
|
{log.details && (
|
||||||
|
<div className="text-xs text-gray-600 mt-1 font-mono bg-gray-50 p-2 rounded-md border border-gray-200 leading-relaxed">
|
||||||
|
{log.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-white rounded-lg shadow p-3 sm:p-4 overflow-x-auto">
|
<div className="bg-white rounded-lg shadow-sm p-3 sm:p-4 overflow-x-auto">
|
||||||
<h2 className="text-base sm:text-lg font-semibold mb-3">Rohdaten</h2>
|
<h2 className="text-sm sm:text-base font-semibold mb-2">Rohdaten</h2>
|
||||||
{loading && <div className="text-center py-6 text-base">Lade Statistiken...</div>}
|
{loading && <div className="text-center py-4 text-sm">Lade Statistiken...</div>}
|
||||||
{error && <div className="text-red-500 text-center text-base">{error}</div>}
|
{error && <div className="text-red-500 text-center text-sm">{error}</div>}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full border border-gray-200 bg-white rounded">
|
<table className="min-w-full border border-gray-200 bg-white rounded text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
<th className="px-3 py-2 text-left text-sm">Slug</th>
|
<th className="px-2 py-1.5 text-left text-xs">Slug</th>
|
||||||
<th className="px-3 py-2 text-right text-sm">Cache-Treffer</th>
|
<th className="px-2 py-1.5 text-right text-xs">Cache-Treffer</th>
|
||||||
<th className="px-3 py-2 text-right text-sm">Cache-Fehlschläge</th>
|
<th className="px-2 py-1.5 text-right text-xs">Cache-Fehlschläge</th>
|
||||||
<th className="px-3 py-2 text-right text-sm">Interpret (ms)</th>
|
<th className="px-2 py-1.5 text-right text-xs">Interpret (ms)</th>
|
||||||
<th className="px-3 py-2 text-right text-sm">Kompilier (ms)</th>
|
<th className="px-2 py-1.5 text-right text-xs">Kompilier (ms)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stats.length === 0 ? (
|
{stats.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="text-center py-3 text-sm">Keine Statistiken verfügbar.</td></tr>
|
<tr><td colSpan={5} className="text-center py-2 text-xs">Keine Statistiken verfügbar.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
stats.map(stat => (
|
stats.map(stat => (
|
||||||
<tr key={stat.slug} className="border-t border-gray-200">
|
<tr key={stat.slug} className="border-t border-gray-200">
|
||||||
<td className="px-3 py-2 font-mono text-sm">{stat.slug}</td>
|
<td className="px-2 py-1.5 font-mono text-xs">{stat.slug}</td>
|
||||||
<td className="px-3 py-2 text-right text-sm">{stat.cache_hits}</td>
|
<td className="px-2 py-1.5 text-right text-xs">{stat.cache_hits}</td>
|
||||||
<td className="px-3 py-2 text-right text-sm">{stat.cache_misses}</td>
|
<td className="px-2 py-1.5 text-right text-xs">{stat.cache_misses}</td>
|
||||||
<td className="px-3 py-2 text-right text-sm">{stat.last_interpret_time_ms}</td>
|
<td className="px-2 py-1.5 text-right text-xs">{stat.last_interpret_time_ms}</td>
|
||||||
<td className="px-3 py-2 text-right text-sm">{stat.last_compile_time_ms}</td>
|
<td className="px-2 py-1.5 text-right text-xs">{stat.last_compile_time_ms}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/*********************************************
|
/*********************************************
|
||||||
* This is the main admin page for the blog.
|
* This is the main admin page for the blog.
|
||||||
*
|
*
|
||||||
* Written Jun 19 2025
|
* Written Jun 19 2025
|
||||||
|
* Rewritten fucking 15 times cause of the
|
||||||
|
* fucking
|
||||||
|
* typescript linter.
|
||||||
|
*
|
||||||
|
* If any Issues about "Window" (For Monaco) pop up. Its not my fucking fault
|
||||||
|
*
|
||||||
|
* Push later when on local Network. (//5jul25) ## Already done
|
||||||
**********************************************/
|
**********************************************/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
@@ -12,8 +20,21 @@ import Link from 'next/link';
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamicImport from 'next/dynamic';
|
||||||
import { Theme } from 'emoji-picker-react';
|
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 {
|
interface Post {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -46,7 +67,18 @@ interface Post {
|
|||||||
|
|
||||||
type Node = Post | Folder;
|
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() {
|
export default function AdminPage() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
@@ -69,12 +101,7 @@ export default function AdminPage() {
|
|||||||
});
|
});
|
||||||
const [showManageContent, setShowManageContent] = useState(false);
|
const [showManageContent, setShowManageContent] = useState(false);
|
||||||
const [managePath, setManagePath] = useState<string[]>([]);
|
const [managePath, setManagePath] = useState<string[]>([]);
|
||||||
const [pinned, setPinned] = useState<string[]>(() => {
|
const [pinned, setPinned] = useState<string[]>([]);
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return JSON.parse(localStorage.getItem('pinnedPosts') || '[]');
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const [pinFeedback, setPinFeedback] = useState<string | null>(null);
|
const [pinFeedback, setPinFeedback] = useState<string | null>(null);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const [changePwOld, setChangePwOld] = useState('');
|
const [changePwOld, setChangePwOld] = useState('');
|
||||||
@@ -84,12 +111,7 @@ export default function AdminPage() {
|
|||||||
const [previewHtml, setPreviewHtml] = useState('');
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
const [editingPost, setEditingPost] = useState<{ slug: string, path: string } | null>(null);
|
const [editingPost, setEditingPost] = useState<{ slug: string, path: string } | null>(null);
|
||||||
const [isDocker, setIsDocker] = useState<boolean>(false);
|
const [isDocker, setIsDocker] = useState<boolean>(false);
|
||||||
const [rememberExportChoice, setRememberExportChoice] = useState<boolean>(() => {
|
const [rememberExportChoice, setRememberExportChoice] = useState<boolean>(false);
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return localStorage.getItem('rememberExportChoice') === 'true';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
const [lastExportChoice, setLastExportChoice] = useState<string | null>(null);
|
const [lastExportChoice, setLastExportChoice] = useState<string | null>(null);
|
||||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState<string | null>(null);
|
const [emojiPickerOpen, setEmojiPickerOpen] = useState<string | null>(null);
|
||||||
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
|
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
|
||||||
@@ -97,6 +119,10 @@ export default function AdminPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const usernameRef = useRef<HTMLInputElement>(null);
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
const passwordRef = 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(() => {
|
useEffect(() => {
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
@@ -129,13 +155,7 @@ export default function AdminPage() {
|
|||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
highlight: function(code: string, lang: string) {
|
renderer,
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
|
||||||
return hljs.highlight(code, { language: lang }).value;
|
|
||||||
} else {
|
|
||||||
return hljs.highlightAuto(code).value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as any);
|
} as any);
|
||||||
setPreviewHtml(marked.parse(newPost.content || '') as string);
|
setPreviewHtml(marked.parse(newPost.content || '') as string);
|
||||||
}, [newPost.content]);
|
}, [newPost.content]);
|
||||||
@@ -518,15 +538,7 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handleExportTarball() {
|
function handleExportTarball() {
|
||||||
// Check if we should use the remembered choice
|
if (typeof window === 'undefined') return;
|
||||||
if (rememberExportChoice && lastExportChoice) {
|
|
||||||
if (lastExportChoice === 'docker') {
|
|
||||||
exportFromEndpoint('/api/admin/export');
|
|
||||||
} else if (lastExportChoice === 'local') {
|
|
||||||
exportFromEndpoint('/api/admin/exportlocal');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create popup modal
|
// Create popup modal
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
@@ -619,6 +631,7 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportFromEndpoint(endpoint: string) {
|
function exportFromEndpoint(endpoint: string) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
fetch(endpoint)
|
fetch(endpoint)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error('Export failed');
|
if (!res.ok) throw new Error('Export failed');
|
||||||
@@ -642,6 +655,15 @@ export default function AdminPage() {
|
|||||||
setLastExportChoice(null);
|
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
|
// Simple and reliable emoji update handler
|
||||||
const handleSetFolderEmoji = async (folderPath: string, emoji: string) => {
|
const handleSetFolderEmoji = async (folderPath: string, emoji: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -752,6 +774,17 @@ export default function AdminPage() {
|
|||||||
return Theme.LIGHT;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
|
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
|
||||||
{pinFeedback && (
|
{pinFeedback && (
|
||||||
@@ -867,6 +900,60 @@ export default function AdminPage() {
|
|||||||
<span className="text-xs font-normal text-teal-100">Statistiken</span>
|
<span className="text-xs font-normal text-teal-100">Statistiken</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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 && (
|
{rememberExportChoice && lastExportChoice && (
|
||||||
<div className="flex items-center gap-1 text-xs text-gray-600 w-full sm:w-auto justify-center sm:justify-start">
|
<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>
|
<span>💾 {lastExportChoice === 'docker' ? 'Docker' : 'Local'}</span>
|
||||||
@@ -990,8 +1077,6 @@ export default function AdminPage() {
|
|||||||
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
|
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Drag and Drop Zone */}
|
{/* Drag and Drop Zone */}
|
||||||
<div
|
<div
|
||||||
className={`mb-6 sm:mb-8 p-4 sm:p-8 border-2 border-dashed rounded-lg text-center ${
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="w-full sm:w-1/2">
|
<div className="w-full sm:w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Inhalt (Markdown)</label>
|
<div style={{ height: '240px' }}>
|
||||||
<textarea
|
<MonacoEditor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="markdown"
|
||||||
value={newPost.content}
|
value={newPost.content}
|
||||||
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
|
onChange={(value?: string) => setNewPost({ ...newPost, content: value || '' })}
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm sm:text-base"
|
options={{
|
||||||
style={{ height: '240px' }}
|
minimap: { enabled: false },
|
||||||
rows={10}
|
wordWrap: 'on',
|
||||||
required
|
fontSize: 14,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
theme: 'vs-light',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
}}
|
||||||
|
onMount={(editor: any) => {
|
||||||
|
monacoRef.current = editor;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="w-full sm:w-1/2">
|
<div className="w-full sm:w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Vorschau</label>
|
<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="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>
|
</div>
|
||||||
|
<style jsx global>{`
|
||||||
|
.prose code, .prose pre {
|
||||||
|
font-family: 'JetBrains Mono', monospace !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,6 +90,66 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const logs = searchParams.get('logs');
|
||||||
|
if (logs === '1') {
|
||||||
|
// Call the Rust backend for parser logs
|
||||||
|
const rustResult = spawnSync(
|
||||||
|
process.cwd() + '/markdown_backend/target/release/markdown_backend',
|
||||||
|
['logs'],
|
||||||
|
{ 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 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
|
// Return the current pinned.json object
|
||||||
try {
|
try {
|
||||||
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||||
@@ -151,3 +211,35 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Error editing post' }, { status: 500 });
|
return NextResponse.json({ error: 'Error editing post' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const clearLogs = searchParams.get('clearLogs');
|
||||||
|
|
||||||
|
if (clearLogs === '1') {
|
||||||
|
// Call the Rust backend to clear parser logs
|
||||||
|
const rustResult = spawnSync(
|
||||||
|
process.cwd() + '/markdown_backend/target/release/markdown_backend',
|
||||||
|
['clear-logs'],
|
||||||
|
{ 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 NextResponse.json({ error: 'Invalid delete operation' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing logs:', error);
|
||||||
|
return NextResponse.json({ error: 'Error clearing logs' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,174 +1,59 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import matter from 'gray-matter';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
import hljs from 'highlight.js';
|
|
||||||
import { getPostsDirectory } from '@/lib/postsDirectory';
|
import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
const postsDirectory = getPostsDirectory();
|
const postsDirectory = getPostsDirectory();
|
||||||
|
|
||||||
// Function to get file creation date
|
|
||||||
function getFileCreationDate(filePath: string): Date {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
return stats.birthtime ?? stats.mtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to generate ID from text (matches frontend logic)
|
|
||||||
function generateId(text: string): string {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
|
||||||
|
|
||||||
// Custom heading renderer to add IDs
|
|
||||||
renderer.heading = (text, level) => {
|
|
||||||
const id = generateId(text);
|
|
||||||
return `<h${level} id="${id}">${text}</h${level}>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
renderer.code = (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>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
marked.setOptions({
|
|
||||||
gfm: true,
|
|
||||||
breaks: true,
|
|
||||||
renderer,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getPostBySlug(slug: string) {
|
|
||||||
const realSlug = slug.replace(/\.md$/, '');
|
|
||||||
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
|
|
||||||
let rustResult;
|
|
||||||
try {
|
|
||||||
// Try Rust backend first
|
|
||||||
rustResult = spawnSync(
|
|
||||||
path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'),
|
|
||||||
['show', realSlug],
|
|
||||||
{ encoding: 'utf-8' }
|
|
||||||
);
|
|
||||||
if (rustResult.status === 0 && rustResult.stdout) {
|
|
||||||
// Expect Rust to output a JSON object matching the post shape
|
|
||||||
const post = JSON.parse(rustResult.stdout);
|
|
||||||
// Map snake_case to camelCase for frontend compatibility
|
|
||||||
post.createdAt = post.created_at;
|
|
||||||
delete post.created_at;
|
|
||||||
return post;
|
|
||||||
} else {
|
|
||||||
console.error('[Rust parser error]', rustResult.stderr || rustResult.error);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Rust parser exception]', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to TypeScript parser
|
|
||||||
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
||||||
const { data, content } = matter(fileContents);
|
|
||||||
const createdAt = getFileCreationDate(fullPath);
|
|
||||||
|
|
||||||
let processedContent = '';
|
|
||||||
try {
|
|
||||||
// Convert markdown to HTML
|
|
||||||
const rawHtml = marked.parse(content);
|
|
||||||
const window = new JSDOM('').window;
|
|
||||||
const purify = DOMPurify(window);
|
|
||||||
processedContent = purify.sanitize(rawHtml as string, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
||||||
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
|
||||||
'pre', 'code', 'em', 'strong', 'del',
|
|
||||||
'hr', 'br', 'img', 'table', 'thead', 'tbody',
|
|
||||||
'tr', 'th', 'td', 'div', 'span', 'iframe'
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
'class', 'id', 'style',
|
|
||||||
'href', 'target', 'rel',
|
|
||||||
'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
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error processing markdown for slug "${realSlug}":`, err);
|
|
||||||
processedContent = `<div class="error-message">
|
|
||||||
<p>Error processing markdown content. Please check the console for details.</p>
|
|
||||||
<pre>${err instanceof Error ? err.message : 'Unknown error'}</pre>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: realSlug,
|
|
||||||
title: data.title,
|
|
||||||
date: data.date,
|
|
||||||
tags: data.tags || [],
|
|
||||||
summary: data.summary,
|
|
||||||
content: processedContent,
|
|
||||||
createdAt: createdAt.toISOString(),
|
|
||||||
author: (process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous') + "'s",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { slug: string[] | string } }
|
{ params }: { params: { slug: string[] | string } }
|
||||||
) {
|
) {
|
||||||
let parser = 'typescript';
|
|
||||||
let rustError = '';
|
|
||||||
try {
|
try {
|
||||||
const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug];
|
const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug];
|
||||||
const slugPath = slugArr.join('/');
|
const slugPath = slugArr.join('/');
|
||||||
let post;
|
|
||||||
try {
|
|
||||||
const rustResult = spawnSync(
|
const rustResult = spawnSync(
|
||||||
path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'),
|
path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'),
|
||||||
['show', slugPath],
|
['show', slugPath],
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
if (rustResult.status === 0 && rustResult.stdout) {
|
if (rustResult.status === 0 && rustResult.stdout) {
|
||||||
post = JSON.parse(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;
|
post.createdAt = post.created_at;
|
||||||
delete post.created_at;
|
delete post.created_at;
|
||||||
parser = 'rust';
|
return NextResponse.json(post);
|
||||||
} else {
|
} else {
|
||||||
rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
|
const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
|
||||||
console.error('[Rust parser error]', rustError);
|
return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
rustError = e instanceof Error ? e.message : String(e);
|
|
||||||
console.error('[Rust parser exception]', rustError);
|
|
||||||
}
|
|
||||||
if (!post) {
|
|
||||||
post = await getPostBySlug(slugPath);
|
|
||||||
}
|
|
||||||
const response = NextResponse.json(post);
|
|
||||||
response.headers.set('X-Parser', parser);
|
|
||||||
if (parser !== 'rust' && rustError) {
|
|
||||||
response.headers.set('X-Rust-Parser-Error', rustError);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading post:', error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: 'Error loading post', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
error: 'Error loading post',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
src/app/api/posts/preview/route.ts
Normal file
26
src/app/api/posts/preview/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import createDOMPurify from 'isomorphic-dompurify';
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import { getPostsDirectory } from '@/lib/postsDirectory';
|
import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||||
|
|
||||||
@@ -73,6 +72,10 @@ async function readPostsDir(dir: string, relDir = '', pinnedData: { pinned: stri
|
|||||||
const posts: any[] = [];
|
const posts: any[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
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 fullPath = path.join(dir, entry.name);
|
||||||
const relPath = relDir ? path.join(relDir, entry.name) : 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 = '';
|
let processedContent = '';
|
||||||
try {
|
try {
|
||||||
const rawHtml = marked.parse(content);
|
const rawHtml = marked.parse(content) as string;
|
||||||
const window = new JSDOM('').window;
|
processedContent = createDOMPurify.sanitize(rawHtml, {
|
||||||
const purify = DOMPurify(window);
|
|
||||||
processedContent = purify.sanitize(rawHtml as string, {
|
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
||||||
@@ -119,7 +120,7 @@ async function getPostByPath(filePath: string, relPath: string, pinnedData: { pi
|
|||||||
'src', 'alt', 'title', 'width', 'height',
|
'src', 'alt', 'title', 'width', 'height',
|
||||||
'frameborder', 'allowfullscreen'
|
'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) {
|
} catch (err) {
|
||||||
console.error(`Error processing markdown for ${relPath}:`, err);
|
console.error(`Error processing markdown for ${relPath}:`, err);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { watchPosts, stopWatching } from '@/lib/markdown';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
// Prevent static generation of this route
|
// Prevent static generation of this route
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -8,29 +8,117 @@ export const runtime = 'nodejs';
|
|||||||
// Store connected clients
|
// Store connected clients
|
||||||
const clients = new Set<ReadableStreamDefaultController>();
|
const clients = new Set<ReadableStreamDefaultController>();
|
||||||
|
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
export async function OPTIONS(request: NextRequest) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
// Add this client to the set
|
// Add this client to the set
|
||||||
clients.add(controller);
|
clients.add(controller);
|
||||||
|
|
||||||
// Send initial connection message
|
// Send initial connection message
|
||||||
|
try {
|
||||||
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
|
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending initial message:', error);
|
||||||
|
clients.delete(controller);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Set up file watcher if not already set up
|
// Set up Rust file watcher if not already set up
|
||||||
if (clients.size === 1) {
|
if (clients.size === 1) {
|
||||||
watchPosts(() => {
|
try {
|
||||||
|
const rustWatcher = spawn(
|
||||||
|
process.cwd() + '/markdown_backend/target/release/markdown_backend',
|
||||||
|
['watch'],
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
rustWatcher.stdout.on('data', (data) => {
|
||||||
|
const message = data.toString().trim();
|
||||||
|
console.log('Rust watcher output:', message);
|
||||||
|
|
||||||
|
if (message.includes('Posts directory changed!')) {
|
||||||
// Notify all connected clients about the update
|
// Notify all connected clients about the update
|
||||||
const message = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() });
|
const updateMessage = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() });
|
||||||
|
const clientsToRemove: ReadableStreamDefaultController[] = [];
|
||||||
|
|
||||||
clients.forEach(client => {
|
clients.forEach(client => {
|
||||||
try {
|
try {
|
||||||
client.enqueue(`data: ${message}\n\n`);
|
client.enqueue(`data: ${updateMessage}\n\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Remove disconnected clients
|
// Mark client for removal
|
||||||
clients.delete(client);
|
clientsToRemove.push(client);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove disconnected clients
|
||||||
|
clientsToRemove.forEach(client => {
|
||||||
|
clients.delete(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stop watching if no clients are connected
|
||||||
|
if (clients.size === 0) {
|
||||||
|
console.log('No clients connected, stopping watcher');
|
||||||
|
rustWatcher.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rustWatcher.stderr.on('data', (data) => {
|
||||||
|
const errorMessage = data.toString().trim();
|
||||||
|
console.error('Rust watcher error:', errorMessage);
|
||||||
|
|
||||||
|
// Don't treat RecvError as a real error - it's expected when the process is terminated
|
||||||
|
if (!errorMessage.includes('RecvError')) {
|
||||||
|
// Send error to clients
|
||||||
|
const errorData = JSON.stringify({ type: 'error', message: errorMessage });
|
||||||
|
const clientsToRemove: ReadableStreamDefaultController[] = [];
|
||||||
|
|
||||||
|
clients.forEach(client => {
|
||||||
|
try {
|
||||||
|
client.enqueue(`data: ${errorData}\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
clientsToRemove.push(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientsToRemove.forEach(client => {
|
||||||
|
clients.delete(client);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rustWatcher.on('error', (error) => {
|
||||||
|
console.error('Rust watcher spawn error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
rustWatcher.on('close', (code) => {
|
||||||
|
console.log('Rust watcher closed with code:', code);
|
||||||
|
// Only restart if we still have clients
|
||||||
|
if (clients.size > 0) {
|
||||||
|
console.log('Restarting watcher due to unexpected close');
|
||||||
|
// The watcher will be restarted when the next client connects
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the watcher process for cleanup
|
||||||
|
(controller as any).rustWatcher = rustWatcher;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up Rust file watcher:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up when client disconnects
|
// Clean up when client disconnects
|
||||||
@@ -39,19 +127,41 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Stop watching if no clients are connected
|
// Stop watching if no clients are connected
|
||||||
if (clients.size === 0) {
|
if (clients.size === 0) {
|
||||||
stopWatching();
|
const rustWatcher = (controller as any).rustWatcher;
|
||||||
|
if (rustWatcher) {
|
||||||
|
console.log('Last client disconnected, stopping watcher');
|
||||||
|
rustWatcher.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// Handle stream cancellation - this is called when the stream is cancelled
|
||||||
|
// We can't access the specific controller here, so we'll handle cleanup in the abort event
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return new NextResponse(stream, {
|
return new NextResponse(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
'Connection': 'keep-alive',
|
'Connection': 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type',
|
||||||
}
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSE route error:', error);
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
1
src/app/monaco-vim.d.ts
vendored
Normal file
1
src/app/monaco-vim.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module 'monaco-vim';
|
||||||
@@ -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
|
// Helper to recursively collect all posts from the tree
|
||||||
function collectPosts(nodes: Node[]): Post[] {
|
function collectPosts(nodes: Node[]): Post[] {
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'post') {
|
if (node.type === 'post' && node.slug !== 'about') {
|
||||||
posts.push(node);
|
posts.push(node);
|
||||||
} else if (node.type === 'folder') {
|
} else if (node.type === 'folder') {
|
||||||
posts = posts.concat(collectPosts(node.children));
|
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>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
</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">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
{post.tags.map((tag: string) => {
|
{post.tags.map((tag: string) => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
@@ -317,7 +327,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Posts */}
|
{/* 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 pinnedPosts = posts.filter((post: any) => post.pinned);
|
||||||
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
||||||
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
|
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>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
</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">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
{post.tags.map((tag: string) => (
|
{post.tags.map((tag: string) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,272 +0,0 @@
|
|||||||
// This is the frontend Markdown parser.
|
|
||||||
// It is written in TypeScript
|
|
||||||
// While I was writing this, only I and God knew how it works.
|
|
||||||
// Now, only God knows.
|
|
||||||
//
|
|
||||||
// If you are trying to understand how it works , and optimize it. Please increse the counter
|
|
||||||
//
|
|
||||||
// Hours wasted here: 12
|
|
||||||
|
|
||||||
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 chokidar from 'chokidar';
|
|
||||||
import type { FSWatcher } from 'chokidar';
|
|
||||||
import hljs from 'highlight.js';
|
|
||||||
import { getPostsDirectory } from './postsDirectory';
|
|
||||||
|
|
||||||
export interface Post {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
tags: string[];
|
|
||||||
summary: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: Date;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const postsDirectory = getPostsDirectory();
|
|
||||||
|
|
||||||
// Function to get file creation date
|
|
||||||
function getFileCreationDate(filePath: string): Date {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
return stats.birthtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to generate ID from text (matches frontend logic)
|
|
||||||
function generateId(text: string): string {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced slugification function that matches GitHub-style anchor links
|
|
||||||
function slugify(text: string): string {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
|
||||||
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
|
|
||||||
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to process anchor links in markdown content
|
|
||||||
function processAnchorLinks(content: string): string {
|
|
||||||
// Find all markdown links that point to anchors (e.g., [text](#anchor))
|
|
||||||
return content.replace(/\[([^\]]+)\]\(#([^)]+)\)/g, (match, linkText, anchor) => {
|
|
||||||
// Only slugify if the anchor doesn't already look like a slug
|
|
||||||
// This prevents double-processing of already-correct anchor links
|
|
||||||
const isAlreadySlugified = /^[a-z0-9-]+$/.test(anchor);
|
|
||||||
const slugifiedAnchor = isAlreadySlugified ? anchor : slugify(anchor);
|
|
||||||
return `[${linkText}](#${slugifiedAnchor})`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility function to debug anchor links (for development)
|
|
||||||
export function debugAnchorLinks(content: string): void {
|
|
||||||
if (process.env.NODE_ENV !== 'development') return;
|
|
||||||
|
|
||||||
console.log('=== Anchor Link Debug Info ===');
|
|
||||||
|
|
||||||
// Extract all headings and their IDs
|
|
||||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
const headings: Array<{ level: number; text: string; id: string }> = [];
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = headingRegex.exec(content)) !== null) {
|
|
||||||
const level = match[1].length;
|
|
||||||
const text = match[2].trim();
|
|
||||||
const id = slugify(text);
|
|
||||||
headings.push({ level, text, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Generated heading IDs:');
|
|
||||||
headings.forEach(({ level, text, id }) => {
|
|
||||||
console.log(` H${level}: "${text}" -> id="${id}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract all anchor links
|
|
||||||
const anchorLinkRegex = /\[([^\]]+)\]\(#([^)]+)\)/g;
|
|
||||||
const anchorLinks: Array<{ linkText: string; originalAnchor: string; slugifiedAnchor: string }> = [];
|
|
||||||
|
|
||||||
while ((match = anchorLinkRegex.exec(content)) !== null) {
|
|
||||||
const linkText = match[1];
|
|
||||||
const originalAnchor = match[2];
|
|
||||||
const slugifiedAnchor = slugify(originalAnchor);
|
|
||||||
anchorLinks.push({ linkText, originalAnchor, slugifiedAnchor });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Anchor links found:');
|
|
||||||
anchorLinks.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
|
|
||||||
const headingExists = headings.some(h => h.id === slugifiedAnchor);
|
|
||||||
const status = headingExists ? '✅' : '❌';
|
|
||||||
console.log(` ${status} [${linkText}](#${originalAnchor}) -> [${linkText}](#${slugifiedAnchor})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show missing headings
|
|
||||||
const missingAnchors = anchorLinks.filter(({ slugifiedAnchor }) =>
|
|
||||||
!headings.some(h => h.id === slugifiedAnchor)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingAnchors.length > 0) {
|
|
||||||
console.warn('Missing headings for these anchor links:');
|
|
||||||
missingAnchors.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
|
|
||||||
console.warn(` - [${linkText}](#${originalAnchor}) -> id="${slugifiedAnchor}"`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('=== End Debug Info ===');
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
|
||||||
|
|
||||||
// Custom heading renderer to add IDs
|
|
||||||
renderer.heading = (text, level) => {
|
|
||||||
const id = slugify(text);
|
|
||||||
return `<h${level} id="${id}">${text}</h${level}>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
renderer.code = (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>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
marked.setOptions({
|
|
||||||
gfm: true,
|
|
||||||
breaks: true,
|
|
||||||
renderer,
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getPostBySlug(slug: string): Promise<Post> {
|
|
||||||
const realSlug = slug.replace(/\.md$/, '');
|
|
||||||
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
|
|
||||||
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
||||||
const { data, content } = matter(fileContents);
|
|
||||||
const createdAt = getFileCreationDate(fullPath);
|
|
||||||
|
|
||||||
let processedContent = '';
|
|
||||||
try {
|
|
||||||
// Debug anchor links in development
|
|
||||||
debugAnchorLinks(content);
|
|
||||||
|
|
||||||
// Process anchor links before parsing markdown
|
|
||||||
const processedMarkdown = processAnchorLinks(content);
|
|
||||||
const rawHtml = marked.parse(processedMarkdown);
|
|
||||||
const window = new JSDOM('').window;
|
|
||||||
const purify = DOMPurify(window);
|
|
||||||
processedContent = purify.sanitize(rawHtml as string, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
||||||
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
|
||||||
'pre', 'code', 'em', 'strong', 'del',
|
|
||||||
'hr', 'br', 'img', 'table', 'thead', 'tbody',
|
|
||||||
'tr', 'th', 'td', 'div', 'span', 'iframe'
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
'class', 'id', 'style',
|
|
||||||
'href', 'target', 'rel',
|
|
||||||
'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
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error processing markdown for ${realSlug}:`, err);
|
|
||||||
processedContent = `<div class="error-message">
|
|
||||||
<p>Error processing markdown content. Please check the console for details.</p>
|
|
||||||
<pre>${err instanceof Error ? err.message : 'Unknown error'}</pre>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: realSlug,
|
|
||||||
title: data.title,
|
|
||||||
date: data.date,
|
|
||||||
tags: data.tags || [],
|
|
||||||
summary: data.summary,
|
|
||||||
content: processedContent,
|
|
||||||
createdAt,
|
|
||||||
author: process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllPosts(): Promise<Post[]> {
|
|
||||||
const fileNames = fs.readdirSync(postsDirectory);
|
|
||||||
const allPostsData = await Promise.all(
|
|
||||||
fileNames
|
|
||||||
.filter((fileName) => fileName.endsWith('.md'))
|
|
||||||
.map(async (fileName) => {
|
|
||||||
const slug = fileName.replace(/\.md$/, '');
|
|
||||||
return getPostBySlug(slug);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by creation date (newest first)
|
|
||||||
return allPostsData.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPostsByTag(tag: string): Promise<Post[]> {
|
|
||||||
const allPosts = await getAllPosts();
|
|
||||||
return allPosts.filter((post) => post.tags.includes(tag));
|
|
||||||
}
|
|
||||||
|
|
||||||
// File watcher setup
|
|
||||||
let watcher: FSWatcher | null = null;
|
|
||||||
let onChangeCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
export function watchPosts(callback: () => void) {
|
|
||||||
if (watcher) {
|
|
||||||
watcher.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeCallback = callback;
|
|
||||||
watcher = chokidar.watch(postsDirectory, {
|
|
||||||
ignored: [
|
|
||||||
/(^|[\/\\])\../, // ignore dotfiles
|
|
||||||
/node_modules/,
|
|
||||||
/\.git/,
|
|
||||||
/\.next/,
|
|
||||||
/\.cache/,
|
|
||||||
/\.DS_Store/,
|
|
||||||
/Thumbs\.db/,
|
|
||||||
/\.tmp$/,
|
|
||||||
/\.temp$/
|
|
||||||
],
|
|
||||||
persistent: true,
|
|
||||||
ignoreInitial: true, // Don't trigger on initial scan
|
|
||||||
awaitWriteFinish: {
|
|
||||||
stabilityThreshold: 1000, // Wait 1 second after file changes
|
|
||||||
pollInterval: 100 // Check every 100ms
|
|
||||||
},
|
|
||||||
usePolling: false, // Use native file system events when possible
|
|
||||||
interval: 1000 // Fallback polling interval (only used if native events fail)
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher
|
|
||||||
.on('add', handleFileChange)
|
|
||||||
.on('change', handleFileChange)
|
|
||||||
.on('unlink', handleFileChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileChange() {
|
|
||||||
if (onChangeCallback) {
|
|
||||||
onChangeCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopWatching() {
|
|
||||||
if (watcher) {
|
|
||||||
watcher.close();
|
|
||||||
watcher = null;
|
|
||||||
}
|
|
||||||
onChangeCallback = null;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user