Compare commits

..

33 Commits

Author SHA1 Message Date
c3c74bb21f i think this might work 2025-07-27 17:13:21 +02:00
e3d8ba1017 Update environment configuration, enhance deployment script, and localize backend messages
All checks were successful
Deploy / build-and-deploy (push) Successful in 31m44s
- Added instructions in .env.local for Docker deployment.
- Improved docker.sh to display deployment status with colored output and added ASCII art.
- Updated main.js to indicate future deprecation of the Electron app.
- Translated various log messages and CLI command outputs in the Rust backend to German for better localization.
- Removed unused asset (peta.png) from the project.
- Updated RustStatusPage component to reflect German translations in UI elements and error messages.
2025-07-06 21:17:22 +02:00
a9879d9fa4 flowcharts
All checks were successful
Deploy / build-and-deploy (push) Successful in 31m22s
2025-07-06 18:02:41 +02:00
7d387080f5 Remove Docker image push step from deployment workflow
All checks were successful
Deploy / build-and-deploy (push) Successful in 31m9s
2025-07-06 12:11:18 +02:00
09a673b65b fuck me
Some checks failed
Deploy / build-and-deploy (push) Failing after 21m46s
2025-07-06 11:49:10 +02:00
6665f65529 did some docker stuff ; deploy this shit .
Some checks failed
Deploy / build-and-deploy (push) Failing after 22m33s
2025-07-06 11:17:22 +02:00
7629164387 Merge pull request 'vim' (#12) from vim into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/12

holy hell if this goes wrong im cooked
2025-07-05 20:25:39 +00:00
21f13ef8ae Enhance blog features and improve backend functionality
- Added a VS Code-style editor with YAML frontmatter support and live preview.
- Implemented force reparse functionality for immediate updates of posts.
- Improved directory scanning with error handling and automatic directory creation.
- Introduced new CLI commands for cache management: `reinterpret-all` and `reparse-post`.
- Enhanced logging for better debugging and monitoring of the Rust backend.
- Updated README to reflect new features and improvements.
2025-07-05 22:23:58 +02:00
f94ddaa3b1 ner 2025-07-05 21:23:05 +02:00
559abe3933 fucking shit working properly now. prod works.
TODO; Fix asset loading :3
2025-07-05 16:01:29 +02:00
525e4fdc35 Refactor navigation links to use Next.js routing and improve post handling
- Updated AboutButton to navigate to the about page using Next.js router.
- Changed HeaderButtons and MobileNav to link directly to the about page.
- Modified Home component to exclude the 'about' post from the posts list.
- Added a helper function to strip YAML frontmatter from post summaries.
- Enhanced API routes to handle reading and writing markdown files for posts.
2025-07-04 17:34:04 +02:00
5a49f37750 vim moddddeee 2025-07-04 12:24:55 +02:00
2a7a0fadce add instructions
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Compiling instructions
2025-07-04 05:31:05 +00:00
bb83c6db33 fixed image clicking stuff
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
2025-06-29 21:11:03 +02:00
a401732d7d updated graphic layout for rust
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
2025-06-29 21:02:57 +02:00
baad7309df changed the readme to be sufficient
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
2025-06-29 18:11:15 +02:00
4da88915f1 Merge pull request 'cleanup' (#11) from cleanup into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/11
2025-06-29 16:01:44 +00:00
5e2c95b08d modified the button! 2025-06-29 18:00:55 +02:00
00fe8e7107 no more typescript parser. only rust!
SSE Changed a bit to fit the Rust Parser
2025-06-29 17:57:59 +02:00
24ef59f0ed cleaned up and added a logging system 2025-06-29 17:44:44 +02:00
b0033a5671 fixed SSE Connection error and Rust error when compiling (last brace in /src/markdown.rs)
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
also updated the markdown post that you see upon entering.
2025-06-29 15:56:30 +02:00
6b5705680a fix: merge conflict, unify docker, recursive, slug, and custom tag logic in markdown parser
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
2025-06-29 14:47:01 +02:00
b4b41ebcfd did some more improvements on the docker/rust things 2025-06-29 14:44:42 +02:00
d660c88c68 Merge pull request 'feature/rust-healthcheck-and-frontend' (#10) from feature/rust-healthcheck-and-frontend into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/10

fine merge
rt - 26jun25
2025-06-28 18:48:18 +00:00
163ced296a shit 2025-06-28 20:40:04 +02:00
ce6037350c shit 2025-06-28 20:38:37 +02:00
e444d0d7ae gaysex 2025-06-27 20:41:04 +02:00
a843208422 shitfuck 2025-06-27 20:30:40 +02:00
8463edd262 Update layout to replace Rust badge and reorder Tailwind CSS badge
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
2025-06-25 22:29:45 +02:00
477d326853 get HTML styling to work partially with rust
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
2025-06-25 21:40:54 +02:00
399509e2b5 new readme
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
2025-06-25 21:21:06 +02:00
2c933b90fd Merge pull request 'german translations and some minor changes to the UI to make it prettier' (#9) from rustparser into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/9
2025-06-25 19:05:24 +00:00
2a0a0c9f38 german translations and some minor changes to the UI to make it prettier 2025-06-25 21:04:42 +02:00
41 changed files with 4374 additions and 1857 deletions

View File

@@ -1,6 +1,9 @@
#-------------------------------------------------------------------- # -----------------------------------------------------------------------# #-------------------------------------------------------------------- # -----------------------------------------------------------------------#
# In here you have to set your socials / links # Explenations of Variables # # In here you have to set your socials / links # Explenations of Variables #
#-------------------------------------------------------------------- # -----------------------------------------------------------------------# #-------------------------------------------------------------------- # -----------------------------------------------------------------------#
# Modify This before deploying with docker / locally #
#---------------------------------------------------------------------#
#
NEXT_PUBLIC_BLOG_OWNER=Rattatwinko # Your Name goes here # NEXT_PUBLIC_BLOG_OWNER=Rattatwinko # Your Name goes here #
NEXT_ABOUT_ME_LINK="http://localhost:80" # Your WebPage goes here # NEXT_ABOUT_ME_LINK="http://localhost:80" # Your WebPage goes here #
NEXT_SOCIAL_INSTAGRAM="http://instagram.com/rattatwinko" # Your Instagram Link goes here # NEXT_SOCIAL_INSTAGRAM="http://instagram.com/rattatwinko" # Your Instagram Link goes here #
@@ -9,3 +12,4 @@ NEXT_SOCIAL_GITHUB_STATE="true" # I Have G
NEXT_SOCIAL_GITHUB_LINK_IF_TRUE="http://github.com/ZockerKatze" # If you have GitHub then paste your link here # NEXT_SOCIAL_GITHUB_LINK_IF_TRUE="http://github.com/ZockerKatze" # If you have GitHub then paste your link here #
NEXT_SOCIAL_BUYMEACOFFEE="https://coff.ee/rattatwinko" NEXT_SOCIAL_BUYMEACOFFEE="https://coff.ee/rattatwinko"
PORT=8080 # This is unused. You can safely delete if you want. # PORT=8080 # This is unused. You can safely delete if you want. #
BASE_URL=/blog # This is the subpath!

View File

@@ -10,19 +10,52 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
run: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install Node.js dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
markdown_backend/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check Rust code
working-directory: markdown_backend
run: cargo check
- name: Install Docker - name: Install Docker
run: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Build Docker image - name: Build Docker image
run: docker build -t markdownblog . run: docker build -t localhost:3002/rattatwinko/markdownblog:latest .
- name: Save Docker image as tarball - name: Save Docker image as tarball
run: docker save markdownblog -o markdownblog-image.tar run: docker save localhost:3002/rattatwinko/markdownblog:latest -o markdownblog-image.tar
- name: Upload Docker image artifact - name: Upload Docker image artifact
run: actions/upload-artifact@v3 --name markdownblog-docker-image --path markdownblog-image.tar uses: actions/upload-artifact@v3
with:
- name: Push Docker image name: markdownblog-docker-image
run: docker push 10.0.0.13:3002/rattatwinko/markdownblog:latest path: markdownblog-image.tar

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ target/
Cargo.lock Cargo.lock
**/*.rs.bk **/*.rs.bk
*.pdb *.pdb
# Cache
cache/

View File

@@ -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

242
README.md
View File

@@ -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
@@ -100,38 +124,45 @@ markdownblog/
│ ├── favicon.ico │ ├── favicon.ico
│ └── site.webmanifest │ └── site.webmanifest
├── electron/ # Desktop application ├── electron/ # Desktop application
│ └── main.js # Electron main process configuration │ └── main.js # Electron main process configuration
├── 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
├── next-env.d.ts # Next.js TypeScript definitions ├── run-local-backend.sh # Local Rust backend runner
├── next.config.js # Next.js configuration ├── next-env.d.ts # Next.js TypeScript definitions
├── package-lock.json # npm lock file ├── next.config.js # Next.js configuration
├── package.json # Dependencies and scripts ├── package-lock.json # npm lock file
├── postcss.config.js # PostCSS configuration ├── package.json # Dependencies and scripts
├── tailwind.config.js # Tailwind CSS configuration ├── postcss.config.js # PostCSS configuration
├── tsconfig.json # TypeScript configuration ├── tailwind.config.js # Tailwind CSS configuration
── LICENSE # MIT License ── tsconfig.json # TypeScript configuration
└── LICENSE # MIT License
``` ```
### 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,74 @@ 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
## Configuring a Base URL for Proxy Hosting
If you want to host your app behind a subpath (e.g. `http://localhost:3000/blog/`), set the base URL in `.env.local`:
```
BASE_URL=/blog
```
This will automatically prefix all internal links, API calls, and static assets with `/blog`. Make sure your reverse proxy (e.g. nginx) is configured to forward requests from `/blog` to your app.
### Example nginx config
```
location /blog/ {
proxy_pass http://localhost:3000/blog/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```

View File

@@ -44,5 +44,37 @@ if ! docker ps | grep -q $CONTAINER_NAME; then
exit 1 exit 1
fi fi
# Output with colors
GREEN='\033[1;32m' # Green
CYAN='\033[1;36m'
RESET='\033[0m'
echo ""
echo "Deployment complete!" echo "Deployment complete!"
echo "App should be available at http://localhost:$PORT" echo ""
echo -e " App is running at: ${GREEN}http://localhost:${PORT}${RESET}"
echo ""
# Rainbow ASCII Art
RAINBOW=(
'\033[1;31m' # Red
'\033[1;33m' # Yellow
'\033[1;32m' # Green
'\033[1;36m' # Cyan
'\033[1;34m' # Blue
'\033[1;35m' # Magenta
)
ASCII=(
" __ ___ __ __ ____ __ "
" / |/ /___ ______/ /______/ /___ _ ______ / __ )/ /___ ____ _"
" / /|_/ / __ \`/ ___/ //_/ __ / __ \\ | /| / / __ \\/ __ / / __ \\/ __ \`/"
" / / / / /_/ / / / ,< / /_/ / /_/ / |/ |/ / / / / /_/ / / /_/ / /_/ / "
"/_/ /_/\\__,_/_/ /_/|_|\\__,_/\\____/|__/|__/_/ /_/_____/_/\\____/\\__, / "
" /____/ "
)
for i in "${!ASCII[@]}"; do
color="${RAINBOW[$((i % ${#RAINBOW[@]}))]}"
echo -e "${color}${ASCII[$i]}${RESET}"
done

View File

@@ -2,6 +2,13 @@ const { app, BrowserWindow } = require('electron');
const path = require('path'); const path = require('path');
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
/*
This will be discontinued in a bit.
Either move to Docker or get fucked.
*/
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
@@ -14,7 +21,9 @@ function createWindow() {
// Load the Next.js app // Load the Next.js app
if (isDev) { if (isDev) {
mainWindow.loadURL('http://localhost:3000'); const baseUrl = process.env.BASE_URL || '';
const url = `http://localhost:3000${baseUrl}`;
mainWindow.loadURL(url);
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} else { } else {
mainWindow.loadFile(path.join(__dirname, '../.next/server/pages/index.html')); mainWindow.loadFile(path.join(__dirname, '../.next/server/pages/index.html'));

357
flowcharts/backend.drawio Normal file
View File

@@ -0,0 +1,357 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" version="27.2.0">
<diagram name="Markdown Backend Flowchart" id="eQoA7ipTtm_-JJKlHUD_">
<mxGraphModel dx="2374" dy="2271" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="KWtHj6Fe61qkRVlhPuuz-440" value="CLI Command" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontStyle=1;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="2040" y="900" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-441" value="Command Type" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="1855" y="900" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-442" value="get_all_posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2055" y="995" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-443" value="get_post_by_slug" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2045" y="1055" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-444" value="get_posts_by_tag" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2045" y="1115" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-445" value="watch_posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2060" y="1195" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-446" value="checkhealth" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2060" y="1255" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-447" value="force_reinterpret_all_posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2025" y="1315" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-539" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-448" target="KWtHj6Fe61qkRVlhPuuz-449">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-448" value="&lt;div&gt;Cache&lt;/div&gt;" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2250" y="995" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-540" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-449" target="KWtHj6Fe61qkRVlhPuuz-475">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-449" value="&lt;div&gt;Cache&lt;/div&gt;" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2250" y="1055" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-450" value="find_markdown_files" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2400" y="955" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-541" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-451" target="KWtHj6Fe61qkRVlhPuuz-453">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-451" value="get_posts_directory" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2400" y="1005" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-452" value="Scan for .md files" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2405" y="1085" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-542" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-453" target="KWtHj6Fe61qkRVlhPuuz-452">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-453" value="slug_to_path" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2415" y="1045" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-454" value="Read File Content" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2585" y="1045" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-455" value="File Size Check" style="rhombus;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2590" y="1095" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-543" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-456" target="KWtHj6Fe61qkRVlhPuuz-473">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-456" value="Parse Frontmatter" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2585" y="1135" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-457" value="gray_matter::parse" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2760" y="1135" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-458" value="process_anchor_links" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2925" y="1135" width="130" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-459" value="process_custom_tags" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2925" y="1185" width="130" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-460" value="Markdown Parser" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2765" y="1185" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-461" value="Parser::new_ext" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2770" y="1225" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-462" value="Event Processing Loop" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2920" y="1225" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-463" value="push_html" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2955" y="1275" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-464" value="AMMONIA Sanitization" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2920" y="1325" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-465" value="Create Post Struct" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2935" y="1365" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-545" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-466" target="KWtHj6Fe61qkRVlhPuuz-467">
<mxGeometry relative="1" as="geometry">
<mxPoint x="2810" y="1320" as="targetPoint" />
<Array as="points">
<mxPoint x="2830" y="1430" />
<mxPoint x="2830" y="1270" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-466" value="Final Post Object" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2935" y="1415" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-467" value="Insert into POST_CACHE" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2565" y="1255" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-468" value="Update POST_STATS" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2575" y="1295" width="130" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-469" value="Return Post" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2600" y="1335" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-470" value="Sort by Date" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2420" y="1125" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-471" value="Update ALL_POSTS_CACHE" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2375" y="1165" width="170" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-472" value="Return Posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2415" y="1205" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-547" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-473" target="KWtHj6Fe61qkRVlhPuuz-467">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-473" value="Error: File too large" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffebee;strokeColor=#c62828;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2580" y="1175" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-475" value="Filter by Tag" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2240" y="1125" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-476" value="Return Filtered Posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2215" y="1165" width="130" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-477" value="Create Watcher" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2400" y="1255" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-478" value="Watch Directory" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2400" y="1295" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-479" value="Clear Caches" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2405" y="1335" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-480" value="Check Posts Directory" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2205" y="1255" width="130" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-481" value="Generate HealthReport" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2200" y="1295" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-482" value="Clear All Caches" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2045" y="1385" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-483" value="Reprocess All Files" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2040" y="1425" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-484" value="Save to Disk" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="2060" y="1465" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-485" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-440" target="KWtHj6Fe61qkRVlhPuuz-441">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-486" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-442">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-487" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-443">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
<mxPoint x="2020" y="1070" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-488" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-444">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
<mxPoint x="2030" y="1130" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-489" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-445">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
<mxPoint x="2040" y="1210" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-490" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-446">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
<mxPoint x="2040" y="1270" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-491" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-441" target="KWtHj6Fe61qkRVlhPuuz-447">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1910" y="1010" />
<mxPoint x="2000" y="1300" />
<mxPoint x="2020" y="1330" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-492" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-442" target="KWtHj6Fe61qkRVlhPuuz-448">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-493" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-443" target="KWtHj6Fe61qkRVlhPuuz-449">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-494" style="exitX=1.009;exitY=0.493;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-448" target="KWtHj6Fe61qkRVlhPuuz-450">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="2360" y="970" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-495" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-450" target="KWtHj6Fe61qkRVlhPuuz-451">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-497" style="exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-449" target="KWtHj6Fe61qkRVlhPuuz-453">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-498" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-453" target="KWtHj6Fe61qkRVlhPuuz-454">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-499" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-454" target="KWtHj6Fe61qkRVlhPuuz-455">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-500" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-455" target="KWtHj6Fe61qkRVlhPuuz-456">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-501" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-456" target="KWtHj6Fe61qkRVlhPuuz-457">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-502" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-457" target="KWtHj6Fe61qkRVlhPuuz-458">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-503" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-458" target="KWtHj6Fe61qkRVlhPuuz-459">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-504" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-459" target="KWtHj6Fe61qkRVlhPuuz-460">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-505" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-460" target="KWtHj6Fe61qkRVlhPuuz-461">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-506" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-461" target="KWtHj6Fe61qkRVlhPuuz-462">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-507" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-462" target="KWtHj6Fe61qkRVlhPuuz-463">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-508" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-463" target="KWtHj6Fe61qkRVlhPuuz-464">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-509" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-464" target="KWtHj6Fe61qkRVlhPuuz-465">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-510" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-465" target="KWtHj6Fe61qkRVlhPuuz-466">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-512" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-467" target="KWtHj6Fe61qkRVlhPuuz-468">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-513" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-468" target="KWtHj6Fe61qkRVlhPuuz-469">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-514" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-452" target="KWtHj6Fe61qkRVlhPuuz-470">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-515" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-470" target="KWtHj6Fe61qkRVlhPuuz-471">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-516" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-471" target="KWtHj6Fe61qkRVlhPuuz-472">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-519" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-444" target="KWtHj6Fe61qkRVlhPuuz-475">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-520" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-475" target="KWtHj6Fe61qkRVlhPuuz-476">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-521" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-445" target="KWtHj6Fe61qkRVlhPuuz-477">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="2270" y="1210" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-522" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-477" target="KWtHj6Fe61qkRVlhPuuz-478">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-523" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-478" target="KWtHj6Fe61qkRVlhPuuz-479">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-524" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-446" target="KWtHj6Fe61qkRVlhPuuz-480">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-525" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-480" target="KWtHj6Fe61qkRVlhPuuz-481">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-526" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-447" target="KWtHj6Fe61qkRVlhPuuz-482">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-527" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-482" target="KWtHj6Fe61qkRVlhPuuz-483">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-528" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-483" target="KWtHj6Fe61qkRVlhPuuz-484">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-531" value="Hit" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2350" y="965" width="40" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-532" value="Miss" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2280" y="1025" width="40" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-533" value="Hit" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2350" y="1035" width="40" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-534" value="Miss" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2270" y="1085" width="40" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-535" value="OK" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2630" y="1065" width="40" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-536" value="Too Large" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="2625" y="1115" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-537" value="&amp;nbsp;" style="text;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="2310" y="1300" width="30" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-546" value="&amp;nbsp;" style="text;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="2360" y="900" width="30" height="30" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-549" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="KWtHj6Fe61qkRVlhPuuz-548" target="KWtHj6Fe61qkRVlhPuuz-440">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="KWtHj6Fe61qkRVlhPuuz-548" value="&lt;div&gt;Frontend API&lt;/div&gt;" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;" vertex="1" parent="1">
<mxGeometry x="2035" y="740" width="120" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,266 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" version="27.2.0">
<diagram name="Docker Build Flowchart" id="docker-build-flow">
<mxGraphModel dx="1426" dy="795" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="start" value="Start Docker Build" style="ellipse;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontStyle=1;fontSize=14;" parent="1" vertex="1">
<mxGeometry x="40" y="40" width="140" height="60" as="geometry" />
</mxCell>
<mxCell id="docker-check" value="Check Docker Daemon" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="220" y="50" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="docker-error" value="Error: Docker daemon not running" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffebee;strokeColor=#c62828;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="220" y="120" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="cleanup-start" value="Cleanup Phase" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontStyle=1;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="400" y="50" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="stop-containers" value="Stop &amp; Remove Containers" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="400" y="110" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="remove-volume" value="Remove Docker Volume" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="400" y="160" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="build-start" value="Build Phase" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontStyle=1;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="580" y="50" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="stage1-start" value="Stage 1: Rust Build" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="110" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="rust-base" value="FROM rust:latest" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="160" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="copy-rust" value="COPY ./markdown_backend" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="210" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="install-musl" value="Install musl-tools" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="260" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="cargo-build" value="cargo build --release" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="310" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="stage2-start" value="Stage 2: Node.js Build" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="360" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="node-base" value="FROM node:20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="410" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="copy-package" value="COPY package*.json" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="460" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="npm-install" value="RUN npm install" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="510" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="Baa9tTqOcb9ER4_EkQ3s-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" target="setup-start">
<mxGeometry relative="1" as="geometry">
<mxPoint x="700" y="573" as="sourcePoint" />
<mxPoint x="727.3399999999999" y="70" as="targetPoint" />
<Array as="points">
<mxPoint x="730" y="574" />
<mxPoint x="730" y="70" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="copy-source" value="COPY . ." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="580" y="560" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="setup-start" value="Setup Phase" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontStyle=1;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="750" y="50" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="create-posts" value="Create /app/posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="110" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="copy-posts" value="COPY posts/*" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="160" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="copy-binary" value="COPY Rust Binary" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="210" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="set-permissions" value="Set Permissions" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="260" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="npm-build" value="npm run build" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="310" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="create-docker-dir" value="Create /app/docker" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="360" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="set-env" value="ENV DOCKER_CONTAINER=true" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="410" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="expose-port" value="EXPOSE 3000" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="460" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="setup-entrypoint" value="Setup Entrypoint" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5fe;strokeColor=#0277bd;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="750" y="510" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="build-complete" value="Build Complete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="930" y="50" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="run-start" value="Run Phase" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontStyle=1;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="930" y="110" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="docker-run" value="docker run" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="170" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="port-mapping" value="Port Mapping 8080:3000" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="220" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="volume-mount" value="Volume Mount" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#ff8f00;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="270" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="entrypoint-start" value="Entrypoint Execution" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="320" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="check-volume" value="Check Volume Empty?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="370" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="copy-builtin" value="Copy Built-in Posts" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="420" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="start-app" value="Start Application" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#7b1fa2;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="930" y="470" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="container-running" value="Container Running" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="930" y="520" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="edge1" parent="1" source="start" target="docker-check" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge2" parent="1" source="docker-check" target="docker-error" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge3" parent="1" source="docker-check" target="cleanup-start" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge4" parent="1" source="cleanup-start" target="stop-containers" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge5" parent="1" source="stop-containers" target="remove-volume" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge6" parent="1" source="remove-volume" target="build-start" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="560" y="175" />
<mxPoint x="560" y="70" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="edge7" parent="1" source="build-start" target="stage1-start" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge8" parent="1" source="stage1-start" target="rust-base" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge9" parent="1" source="rust-base" target="copy-rust" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge10" parent="1" source="copy-rust" target="install-musl" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge11" parent="1" source="install-musl" target="cargo-build" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge13" parent="1" source="stage2-start" target="node-base" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge14" parent="1" source="node-base" target="copy-package" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge15" parent="1" source="copy-package" target="npm-install" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge16" parent="1" source="npm-install" target="copy-source" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge18" parent="1" source="setup-start" target="create-posts" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge19" parent="1" source="create-posts" target="copy-posts" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge20" parent="1" source="copy-posts" target="copy-binary" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge21" parent="1" source="copy-binary" target="set-permissions" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge22" parent="1" source="set-permissions" target="npm-build" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge23" parent="1" source="npm-build" target="create-docker-dir" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge24" parent="1" source="create-docker-dir" target="set-env" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge25" parent="1" source="set-env" target="expose-port" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge26" parent="1" source="expose-port" target="setup-entrypoint" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge27" parent="1" source="setup-entrypoint" target="build-complete" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="910" y="525" />
<mxPoint x="910" y="70" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="edge28" parent="1" source="build-complete" target="run-start" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge29" parent="1" source="run-start" target="docker-run" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge30" parent="1" source="docker-run" target="port-mapping" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge31" parent="1" source="port-mapping" target="volume-mount" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge32" parent="1" source="volume-mount" target="entrypoint-start" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge33" parent="1" source="entrypoint-start" target="check-volume" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge34" parent="1" source="check-volume" target="copy-builtin" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge36" parent="1" source="copy-builtin" target="start-app" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge37" parent="1" source="start-app" target="container-running" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="edge38" parent="1" source="container-running" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1000" y="580" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="label1" value="Running" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="1" vertex="1">
<mxGeometry x="360" y="50" width="40" height="20" as="geometry" />
</mxCell>
<mxCell id="label2" value="Not Running" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="1" vertex="1">
<mxGeometry x="290" y="90" width="50" height="20" as="geometry" />
</mxCell>
<mxCell id="label3" value="Empty" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="1" vertex="1">
<mxGeometry x="1030" y="350" width="30" height="20" as="geometry" />
</mxCell>
<mxCell id="label4" value="Not Empty" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="1" vertex="1">
<mxGeometry x="1030" y="400" width="40" height="20" as="geometry" />
</mxCell>
<mxCell id="Baa9tTqOcb9ER4_EkQ3s-2" value="Deployment Done." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#2e7d32;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="930" y="580" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="Baa9tTqOcb9ER4_EkQ3s-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0.093;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="cargo-build" target="stage2-start">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -1,12 +1,32 @@
#[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::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 = "Ein CLI für die Verwaltung von Markdown-Blogbeiträgen", long_about = None)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
@@ -28,10 +48,32 @@ enum Commands {
Watch, Watch,
/// Show Rust parser statistics /// Show Rust parser statistics
Rsparseinfo, Rsparseinfo,
/// Check backend health
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 {
#[arg(long)]
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
ast: bool,
},
} }
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 => {
@@ -61,9 +103,9 @@ fn main() {
println!("{}", serde_json::to_string(&posts).unwrap()); println!("{}", serde_json::to_string(&posts).unwrap());
} }
Commands::Watch => { Commands::Watch => {
println!("Watching for changes in posts directory. Press Ctrl+C to exit."); println!("Überwache Änderungen im Posts-Verzeichnis. Drücken Sie Strg+C zum Beenden.");
let _ = watch_posts(|| { let _ = watch_posts(|| {
println!("Posts directory changed!"); println!("Posts-Verzeichnis hat sich geändert!");
}); });
// Keep the main thread alive // Keep the main thread alive
loop { loop {
@@ -73,5 +115,73 @@ fn main() {
Commands::Rsparseinfo => { Commands::Rsparseinfo => {
println!("{}", markdown::rsparseinfo()); println!("{}", markdown::rsparseinfo());
} }
Commands::Checkhealth => {
let health = markdown::checkhealth();
println!("{}", serde_json::to_string_pretty(&health).unwrap());
}
Commands::Logs => {
let logs = get_parser_logs();
println!("{}", serde_json::to_string_pretty(&logs).unwrap());
}
Commands::ClearLogs => {
clear_parser_logs();
println!("{}", serde_json::to_string(&serde_json::json!({"success": true, "message": "Protokolle gelöscht"})).unwrap());
}
Commands::ReinterpretAll => {
match force_reinterpret_all_posts() {
Ok(posts) => {
println!("{}", serde_json::to_string(&serde_json::json!({
"success": true,
"message": format!("Alle Beiträge erfolgreich neu interpretiert. {} Beiträge verarbeitet.", posts.len())
})).unwrap());
}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
Commands::ReparsePost { slug } => {
match force_reparse_single_post(slug) {
Ok(post) => {
println!("{}", serde_json::to_string(&serde_json::json!({
"success": true,
"message": format!("Beitrag '{}' erfolgreich neu geparst", 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();
io::stdin().read_to_string(&mut buffer).unwrap();
buffer
} else if let Some(file_path) = file {
fs::read_to_string(file_path).unwrap()
} else {
eprintln!("Entweder --file oder --stdin muss angegeben werden");
std::process::exit(1);
};
if *ast {
// Parse and output AST as debug format
let parser = pulldown_cmark::Parser::new_ext(&content, pulldown_cmark::Options::all());
let events: Vec<_> = parser.collect();
for event in events {
println!("{:?}", event);
}
} else {
// Parse and output HTML
let parser = pulldown_cmark::Parser::new_ext(&content, pulldown_cmark::Options::all());
let mut html_output = String::new();
pulldown_cmark::html::push_html(&mut html_output, parser);
println!("{}", html_output);
}
}
} }
} }

View File

@@ -1,47 +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!
*/
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 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,
@@ -53,53 +55,312 @@ 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"
} }
// Data Structures for Health Reporting
#[derive(Debug, Serialize)]
pub struct HealthReport {
pub posts_dir_exists: bool,
pub posts_count: usize,
pub cache_file_exists: bool,
pub cache_stats_file_exists: bool,
pub cache_readable: bool,
pub cache_stats_readable: bool,
pub cache_post_count: Option<usize>,
pub cache_stats_count: Option<usize>,
pub errors: Vec<String>,
}
// Log Data Structure (frontend related)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String, // "info", "warning", "error"
pub message: String,
pub slug: Option<String>,
pub details: Option<String>,
}
// Static caches
static POST_CACHE: Lazy<RwLock<HashMap<String, Post>>> = Lazy::new(|| RwLock::new(HashMap::new())); static POST_CACHE: Lazy<RwLock<HashMap<String, Post>>> = Lazy::new(|| RwLock::new(HashMap::new()));
static ALL_POSTS_CACHE: Lazy<RwLock<Option<Vec<Post>>>> = Lazy::new(|| RwLock::new(None)); 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())); static POST_STATS: Lazy<RwLock<HashMap<String, PostStats>>> = Lazy::new(|| RwLock::new(HashMap::new()));
static PARSER_LOGS: Lazy<RwLock<VecDeque<LogEntry>>> = Lazy::new(|| RwLock::new(VecDeque::new()));
// Ammonia HTML sanitizer configuration
static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| {
let mut builder = ammonia::Builder::default();
// Add allowed attributes for various HTML tags
builder.add_tag_attributes("h1", &["style", "id"]);
builder.add_tag_attributes("h2", &["style", "id"]);
builder.add_tag_attributes("h3", &["style", "id"]);
builder.add_tag_attributes("h4", &["style", "id"]);
builder.add_tag_attributes("h5", &["style", "id"]);
builder.add_tag_attributes("h6", &["style", "id"]);
builder.add_tag_attributes("p", &["style"]);
builder.add_tag_attributes("span", &["style"]);
builder.add_tag_attributes("strong", &["style"]);
builder.add_tag_attributes("em", &["style"]);
builder.add_tag_attributes("b", &["style"]);
builder.add_tag_attributes("i", &["style"]);
builder.add_tag_attributes("u", &["style"]);
builder.add_tag_attributes("mark", &["style"]);
builder.add_tag_attributes("small", &["style"]);
builder.add_tag_attributes("abbr", &["style"]);
builder.add_tag_attributes("cite", &["style"]);
builder.add_tag_attributes("q", &["style"]);
builder.add_tag_attributes("code", &["style"]);
builder.add_tag_attributes("pre", &["style"]);
builder.add_tag_attributes("kbd", &["style"]);
builder.add_tag_attributes("samp", &["style"]);
builder.add_tag_attributes("section", &["style"]);
builder.add_tag_attributes("article", &["style"]);
builder.add_tag_attributes("header", &["style"]);
builder.add_tag_attributes("footer", &["style"]);
builder.add_tag_attributes("main", &["style"]);
builder.add_tag_attributes("aside", &["style"]);
builder.add_tag_attributes("nav", &["style"]);
builder.add_tag_attributes("ul", &["style"]);
builder.add_tag_attributes("ol", &["style"]);
builder.add_tag_attributes("li", &["style"]);
builder.add_tag_attributes("dl", &["style"]);
builder.add_tag_attributes("dt", &["style"]);
builder.add_tag_attributes("dd", &["style"]);
builder.add_tag_attributes("table", &["style"]);
builder.add_tag_attributes("thead", &["style"]);
builder.add_tag_attributes("tbody", &["style"]);
builder.add_tag_attributes("tfoot", &["style"]);
builder.add_tag_attributes("tr", &["style"]);
builder.add_tag_attributes("td", &["style"]);
builder.add_tag_attributes("th", &["style"]);
builder.add_tag_attributes("a", &["style"]);
builder.add_tag_attributes("img", &["style"]);
builder.add_tag_attributes("video", &["style"]);
builder.add_tag_attributes("audio", &["style"]);
builder.add_tag_attributes("source", &["style"]);
builder.add_tag_attributes("iframe", &["style"]);
builder.add_tag_attributes("sup", &["style"]);
builder.add_tag_attributes("sub", &["style"]);
builder.add_tag_attributes("time", &["style"]);
builder.add_tag_attributes("var", &["style"]);
builder.add_tag_attributes("del", &["style"]);
builder.add_tag_attributes("ins", &["style"]);
builder.add_tag_attributes("br", &["style"]);
builder.add_tag_attributes("wbr", &["style"]);
builder.add_tag_attributes("form", &["style"]);
builder.add_tag_attributes("input", &["style"]);
builder.add_tag_attributes("textarea", &["style"]);
builder.add_tag_attributes("select", &["style"]);
builder.add_tag_attributes("option", &["style"]);
builder.add_tag_attributes("button", &["style"]);
builder.add_tag_attributes("label", &["style"]);
builder.add_tag_attributes("fieldset", &["style"]);
builder.add_tag_attributes("legend", &["style"]);
builder.add_tag_attributes("blockquote", &["style"]);
builder.add_tag_attributes("font", &["style"]);
builder.add_tag_attributes("center", &["style"]);
builder.add_tag_attributes("big", &["style"]);
builder.add_tag_attributes("tt", &["style"]);
// Add class attribute for div
builder.add_tag_attributes("div", &["style", "class"]);
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!("Fehler beim Erstellen des Cache-Verzeichnisses: {}", e);
add_log("error", &format!("Fehler beim Erstellen des Cache-Verzeichnisses: {}", e), None, None);
} else {
add_log("info", "Cache-Verzeichnis erstellt: ./cache", None, None);
}
}
}
fn get_posts_directory() -> PathBuf { fn get_posts_directory() -> PathBuf {
let candidates = [ let is_docker = std::env::var("DOCKER_CONTAINER").is_ok()
"./posts", || std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
"../posts", || std::path::Path::new("/.dockerenv").exists();
"/posts",
"/docker" 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() { for candidate in candidates.iter() {
let path = PathBuf::from(candidate); let path = PathBuf::from(candidate);
if path.exists() && path.is_dir() { if path.exists() && path.is_dir() {
add_log("info", &format!("Verwende Posts-Verzeichnis: {:?}", path), None, None);
return path; return path;
} }
} }
// Fallback: default to ./posts
PathBuf::from("./posts") // Fallback: create ./posts if it doesn't exist
let fallback_path = PathBuf::from("./posts");
if !fallback_path.exists() {
if let Err(e) = fs::create_dir_all(&fallback_path) {
add_log("error", &format!("Fehler beim Erstellen des Posts-Verzeichnisses: {}", e), None, None);
} else {
add_log("info", "Posts-Verzeichnis erstellt: ./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!("Verzeichnis existiert nicht: {:?}", 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!("Pfad ist kein Verzeichnis: {:?}", 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!("Fehler beim Lesen des Verzeichnisses {:?}: {}", 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!("Fehler beim Scannen des Unterverzeichnisses {:?}: {}", 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!("Datei nicht zugänglich {:?}: {}", path, e);
add_log("warning", &error_msg, None, None);
errors.push(error_msg);
}
}
}
}
Err(e) => {
let error_msg = format!("Fehler beim Lesen des Verzeichniseintrags: {}", e);
add_log("warning", &error_msg, None, None);
errors.push(error_msg);
}
}
}
// Log summary
add_log("info", &format!("{} Markdown-Dateien in {:?} gefunden", files.len(), dir), None, None);
if !errors.is_empty() {
add_log("warning", &format!("{} Fehler während der Verzeichnissuche aufgetreten", 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>> { fn get_file_creation_date(path: &Path) -> std::io::Result<DateTime<Utc>> {
let metadata = fs::metadata(path)?; let metadata = fs::metadata(path)?;
// Try to get creation time, fall back to modification time if not available
match metadata.created() { match metadata.created() {
Ok(created) => Ok(DateTime::<Utc>::from(created)), Ok(created) => Ok(DateTime::<Utc>::from(created)),
Err(_) => { Err(_) => {
// Fall back to modification time if creation time is not available
let modified = metadata.modified()?; let modified = metadata.modified()?;
Ok(DateTime::<Utc>::from(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 { fn process_anchor_links(content: &str) -> String {
// Replace [text](#anchor) with slugified anchor
let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap(); let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap();
re.replace_all(content, |caps: &regex::Captures| { re.replace_all(content, |caps: &regex::Captures| {
let link_text = &caps[1]; let link_text = &caps[1];
@@ -109,15 +370,12 @@ fn process_anchor_links(content: &str) -> String {
}).to_string() }).to_string()
} }
// Helper function to strip emojis from a string // Here we just remove the Emoji if it is in the heading.
// Neccesary for the slugify function to work correctly. And the ID's to work with the frontend. // Example "🏳️‍🌈 Hi!" will turn into "#hi"
fn strip_emojis(s: &str) -> String { 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() s.chars()
.filter(|c| { .filter(|c| {
let c = *c as u32; let c = *c as u32;
// Basic Emoji ranges
!( (c >= 0x1F600 && c <= 0x1F64F) // Emoticons !( (c >= 0x1F600 && c <= 0x1F64F) // Emoticons
|| (c >= 0x1F300 && c <= 0x1F5FF) // Misc Symbols and Pictographs || (c >= 0x1F300 && c <= 0x1F5FF) // Misc Symbols and Pictographs
|| (c >= 0x1F680 && c <= 0x1F6FF) // Transport and Map || (c >= 0x1F680 && c <= 0x1F6FF) // Transport and Map
@@ -131,19 +389,94 @@ fn strip_emojis(s: &str) -> String {
.collect() .collect()
} }
static AMMONIA: Lazy<ammonia::Builder<'static>> = Lazy::new(|| { // This is a obsolete Function for Custom Tags for HTML
let mut builder = ammonia::Builder::default(); // Example usage in Text: <warning />
builder.add_tag_attributes("h1", &["id"]); fn process_custom_tags(content: &str) -> String {
builder.add_tag_attributes("h2", &["id"]); let mut processed = content.to_string();
builder.add_tag_attributes("h3", &["id"]);
builder.add_tag_attributes("h4", &["id"]);
builder.add_tag_attributes("h5", &["id"]);
builder.add_tag_attributes("h6", &["id"]);
builder
});
// Handle simple tags without parameters
let simple_tags = [
("<mytag />", "<div class=\"custom-tag mytag\">Dies ist mein benutzerdefinierter Tag-Inhalt!</div>"),
("<warning />", "<div class=\"custom-tag warning\" style=\"background: #fff3cd; border: 1px solid #ffeaa7; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">⚠️ Warnung: Dies ist ein benutzerdefiniertes Warnungs-Tag!</div>"),
("<info />", "<div class=\"custom-tag info\" style=\"background: #d1ecf1; border: 1px solid #bee5eb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\"> Info: Dies ist ein benutzerdefiniertes Info-Tag!</div>"),
("<success />", "<div class=\"custom-tag success\" style=\"background: #d4edda; border: 1px solid #c3e6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">✅ Erfolg: Dies ist ein benutzerdefiniertes Erfolgs-Tag!</div>"),
("<error />", "<div class=\"custom-tag error\" style=\"background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; margin: 1rem 0;\">❌ Fehler: Dies ist ein benutzerdefiniertes Fehler-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: &regex::Captures| {
let tag_name = &caps[1];
let params = &caps[2];
match tag_name {
"mytag" => {
format!("<div class=\"custom-tag mytag\" data-params=\"{}\">Benutzerdefinierter Inhalt mit Parametern: {}</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;\">⚠️ Warnungs-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;\">❌ Fehler-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 {}\">Unbekanntes benutzerdefiniertes 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();
@@ -154,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", "Starte 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;
@@ -173,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-Treffer", 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!("Datei nicht gefunden: {:?}", 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!("Datei geladen: {} Bytes", file_content.len()), Some(slug), None);
if file_content.len() > MAX_FILE_SIZE {
let error_msg = format!("Datei zu groß: {} 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);
@@ -189,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!("Fehler beim Deserialisieren des Frontmatters: {}", 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", "Kein Frontmatter gefunden", Some(slug), None);
return Err("No frontmatter found".into()); return Err("Kein Frontmatter gefunden".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_anchor_links(&result.content);
let processed_markdown = process_custom_tags(&processed_markdown);
add_log("info", "Starte Markdown-Parsing", Some(slug), Some(&format!("Inhaltslänge: {} Zeichen", processed_markdown.len())));
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();
@@ -210,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 - Datei zu groß";
add_log("error", error_msg, Some(slug), Some(&format!("{} Events verarbeitet", 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;
@@ -222,10 +593,10 @@ 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);
events.push(Event::Html(CowStr::Boxed(format!("<h{lvl} id=\"{id}\">", lvl=heading_level, id=id).into_boxed_str()))); 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::Text(CowStr::Boxed(heading_text.clone().into_boxed_str()))); events.push(Event::Text(CowStr::Boxed(heading_text.clone().into_boxed_str())));
events.push(Event::Html(CowStr::Boxed(format!("</h{lvl}>", lvl=heading_level).into_boxed_str()))); events.push(Event::Html(CowStr::Boxed(format!("</h{lvl}>", lvl=heading_level).into_boxed_str())));
}, },
@@ -242,16 +613,14 @@ 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><code>{}</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)))
} else { } else {
format!("<pre><code>{}</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><code>{}</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())));
}, },
@@ -264,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 abgeschlossen", Some(slug), Some(&format!("{} Events verarbeitet", 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();
@@ -278,11 +649,14 @@ pub fn get_post_by_slug(slug: &str) -> Result<Post, Box<dyn std::error::Error>>
summary: front.summary, summary: front.summary,
content: sanitized_html, content: sanitized_html,
created_at: created_at.to_rfc3339(), created_at: created_at.to_rfc3339(),
author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonymous".to_string()), author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonym".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 {
@@ -293,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 erfolgreich abgeschlossen", Some(slug), Some(&format!("Interpretation: {}ms, Kompilierung: {}ms", interpret_time.as_millis(), compile_time.as_millis())));
Ok(post) Ok(post)
} }
@@ -301,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) { posts.push(post);
// 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.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)
} }
@@ -330,17 +705,17 @@ 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();
}, },
Err(e) => { Err(e) => {
eprintln!("watch error: {:?}", e); eprintln!("Überwachungsfehler: {:?}", e);
break; break;
} }
} }
@@ -363,12 +738,174 @@ 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);
} }
} }
pub fn checkhealth() -> HealthReport {
let mut errors = Vec::new();
let posts_dir = get_posts_directory();
let posts_dir_exists = posts_dir.exists() && posts_dir.is_dir();
let mut posts_count = 0;
if posts_dir_exists {
match std::fs::read_dir(&posts_dir) {
Ok(entries) => {
posts_count = entries.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
.count();
},
Err(e) => errors.push(format!("Fehler beim Lesen des Posts-Verzeichnisses: {}", e)),
}
} else {
errors.push("Posts-Verzeichnis existiert nicht".to_string());
}
let cache_file_exists = Path::new(POSTS_CACHE_PATH).exists();
let cache_stats_file_exists = Path::new(POST_STATS_PATH).exists();
let (mut cache_readable, mut cache_post_count) = (false, None);
if cache_file_exists {
match std::fs::read_to_string(POSTS_CACHE_PATH) {
Ok(data) => {
match serde_json::from_str::<HashMap<String, Post>>(&data) {
Ok(map) => {
cache_readable = true;
cache_post_count = Some(map.len());
},
Err(e) => errors.push(format!("Cache-Datei ist kein gültiges JSON: {}", e)),
}
},
Err(e) => errors.push(format!("Fehler beim Lesen der Cache-Datei: {}", e)),
}
}
let (mut cache_stats_readable, mut cache_stats_count) = (false, None);
if cache_stats_file_exists {
match std::fs::read_to_string(POST_STATS_PATH) {
Ok(data) => {
match serde_json::from_str::<HashMap<String, PostStats>>(&data) {
Ok(map) => {
cache_stats_readable = true;
cache_stats_count = Some(map.len());
},
Err(e) => errors.push(format!("Cache-Statistik-Datei ist kein gültiges JSON: {}", e)),
}
},
Err(e) => errors.push(format!("Fehler beim Lesen der Cache-Statistik-Datei: {}", e)),
}
}
HealthReport {
posts_dir_exists,
posts_count,
cache_file_exists,
cache_stats_file_exists,
cache_readable,
cache_stats_readable,
cache_post_count,
cache_stats_count,
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!("Fehler beim Speichern leerer Protokolle auf Festplatte: {}", 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", "Starte erzwungene Neuinterpretation aller 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", "Alle Caches geleert", 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!("{} Markdown-Dateien zur Neuinterpretation gefunden", 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!("Erfolgreich neuinterpretiert: {}", slug), Some(&slug), None);
}
Err(e) => {
error_count += 1;
add_log("error", &format!("Fehler bei der Neuinterpretation von {}: {}", 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!("Erzwungene Neuinterpretation abgeschlossen. Erfolgreich: {}, Fehler: {}", 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!("Starte erzwungenes Neuparsing des Posts: {}", 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!("Cache für Post geleert: {}", 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!("Post erfolgreich neugeparst: {}", slug), Some(slug), None);
Ok(post)
}

View File

@@ -7,6 +7,10 @@ const nextConfig = {
experimental: { experimental: {
serverComponentsExternalPackages: ['chokidar'] serverComponentsExternalPackages: ['chokidar']
}, },
basePath: process.env.BASE_URL || '',
env: {
NEXT_PUBLIC_BASE_URL: process.env.BASE_URL || '',
},
// Handle API routes that shouldn't be statically generated // Handle API routes that shouldn't be statically generated
async headers() { async headers() {
return [ return [

199
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

9
posts/about.md Normal file
View File

@@ -0,0 +1,9 @@
---
title: About Me
date: 2025-07-04
tags: [about, profile]
author: rattatwinko
summary: This is the about page
---
_**config this in the monaco editor in the admin panel**_

View File

@@ -24,13 +24,15 @@ author: Rattatwinko
- [Features 🎉](#features) - [Features 🎉](#features)
- [Administration 🚧](#administration) - [Administration 🚧](#administration)
- [Customization 🎨](#customization) - [Customization 🎨](#customization)
- [Creating Posts with MdB ✍](#creating-posts-with-mdb)
- [Troubleshooting 🚨](#troubleshooting) - [Troubleshooting 🚨](#troubleshooting)
- [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)
--- ---
@@ -301,6 +303,35 @@ The codebase is well-structured and documented. Key files:
--- ---
## Creating Posts with MdB
If you are reading posts. Then you probably dont need this explenation!
Else you should read this.
First of all, if you are creating posts within the terminal. then you should create posts with the following headers.
```Markdown
---
title: Welcome to MarkdownBlog
date: '2025-06-19'
tags:
- welcome
- introduction
- getting-started
- documentation
summary: A comprehensive guide to getting started with MarkdownBlog
author: Rattatwinko
---
```
As you can see this is the header for the current Post.
You can write this like YML (idk).
If you are writing posts within the Admin-Panel then you are a _lucky piece of shit_ cause there it does that **automatically**
---
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
@@ -402,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.
@@ -413,10 +443,88 @@ If you have seen this is not very mindfull of browser resources tho.
|<span style="color:pink;">IS DONE</span>|Task| |<span style="color:pink;">IS DONE</span>|Task|
|-------|----| |-------|----|
|<span style="color:red;">partly</span> / <span style="color:orange;">working on it</span>|_Rewrite_ the Markdown Parser in **Rust** ; This works for local Builds but in Docker does not work due to permission error| |<span style="color:green;">Done</span>|**Rust Parser for Markdown**|
|<span style="color:lightblue;">LTS</span>|_Long Term Support and upkeep_|
|<span style="color:lime;">Partially Done</span>|**Caching with Rust**|
|<span style="color:green;">Done</span>|Full Inline _CSS_ Support for **HTML**|
--- ## 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">
<p style="display:block;margin:0 auto;text-align:center;font-style:italic;font-weight:bold;">Ferris the Cutie</p>
<br />
### 🚀 **Major Updates (Latest)**
#### **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)
#### **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
#### **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
### 🔧 **Technical Improvements**
#### **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
#### **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 />
<!--Markdown Image :heart:--> <!--Markdown Image :heart:-->
<img src="https://blog.cyon.ch/wp-content/uploads/2016/05/i-love-markdown.png" alt="I looooooove Markdown" style="display:block;margin:0 auto;"> <img src="https://blog.cyon.ch/wp-content/uploads/2016/05/i-love-markdown.png" alt="I looooooove Markdown" style="display:block;margin:0 auto;">
@@ -427,3 +535,9 @@ If you have seen this is not very mindfull of browser resources tho.
> *"DEVELOPERS! DEVELOPERS! DEVELOPERS!"* - Steve Ballmer > *"DEVELOPERS! DEVELOPERS! DEVELOPERS!"* - Steve Ballmer
> >
> <cite>— Rattatwinko, 2025 Q3</cite> > <cite>— Rattatwinko, 2025 Q3</cite>
## Hosting behind a subpath (nginx proxy)
If you want to serve your blog at a subpath (e.g. `/blog`), set `BASE_URL=/blog` in your `.env.local` file. All internal links and API calls will use this base path automatically.
Example: Your blog will be available at `http://localhost:3000/blog`

22
run-local-backend.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# This script builds and runs the Rust backend locally, similar to the Docker container.
# Usage: ./run-local-backend.sh [args for markdown_backend]
# AnalSex with the frontend ( Cursor Autocompletion xD )
set -e
# Set environment variables as in Docker (customize as needed)
export BLOG_OWNER=${BLOG_OWNER:-"rattatwinko"}
# Build the backend in release mode
cd "$(dirname "$0")/markdown_backend"
echo "Building Rust backend..."
cargo build --release
# Run the backend with any arguments passed to the script
cd target/release
echo "Running: ./markdown_backend $@"
./markdown_backend "$@"
npm run dev ## start the fuckass frontend

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import BadgeButton from './BadgeButton'; import BadgeButton from './BadgeButton';
import { useRouter } from 'next/navigation';
import { withBaseUrl } from '@/lib/baseUrl';
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 +11,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(withBaseUrl('/posts/about'))}
if (typeof window !== 'undefined') {
window.open('http://' + window.location.hostname + ':80', '_blank');
}
}}
/> />
); );
} }

View File

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

View File

@@ -1,19 +1,18 @@
'use client'; 'use client';
import BadgeButton from './BadgeButton'; import BadgeButton from './BadgeButton';
import AboutButton from './AboutButton'; import { withBaseUrl } from '@/lib/baseUrl';
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>
); );
@@ -21,28 +20,31 @@ export default function HeaderButtons() {
return ( return (
<div className="flex gap-2 justify-center sm:justify-end"> <div className="flex gap-2 justify-center sm:justify-end">
<a <a
href="/admin" href={withBaseUrl('/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={withBaseUrl('/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>

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { withBaseUrl } from '@/lib/baseUrl';
interface MobileNavProps { interface MobileNavProps {
blogOwner: string; blogOwner: string;
@@ -54,27 +55,15 @@ export default function MobileNav({ blogOwner }: MobileNavProps) {
<h2 className="text-lg font-bold mb-6">{blogOwner}&apos;s Blog</h2> <h2 className="text-lg font-bold mb-6">{blogOwner}&apos;s Blog</h2>
<nav className="space-y-4"> <nav className="space-y-4">
<Link <a href={withBaseUrl('/')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
href="/"
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
onClick={toggleMenu}
>
🏠 Home 🏠 Home
</Link> </a>
<Link <a href={withBaseUrl('/admin')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
href="/admin"
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
onClick={toggleMenu}
>
🔐 Admin 🔐 Admin
</Link> </a>
<a <a href={withBaseUrl('/posts/about')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
onClick={toggleMenu}
>
👤 About Me 👤 About Me
</a> </a>
</nav> </nav>

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

@@ -0,0 +1,99 @@
"use client";
import React, { useEffect, useState } from "react";
import { withBaseUrl } from '@/lib/baseUrl';
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(withBaseUrl('/api/posts/about'));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setPost(data);
} catch (error) {
setError(error instanceof Error ? error.message : "Unknown error");
} finally {
setLoading(false);
}
};
loadAbout();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Lade About...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Fehler beim Laden</h1>
<p className="text-gray-600 mb-6">{error}</p>
</div>
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">About nicht gefunden</h1>
<p className="text-gray-600 mb-6">Die About-Seite konnte nicht gefunden werden.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<article className="bg-white rounded-lg shadow-sm border p-6 sm:p-8">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
{post.title || "About"}
</h1>
{post.summary && (
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
{post.summary}
</p>
)}
</header>
<div
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 prose-code:text-gray-800 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-900 prose-pre:text-gray-100"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</main>
</div>
);
}

View File

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

View File

@@ -0,0 +1,622 @@
"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";
import { withBaseUrl } from '@/lib/baseUrl';
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(withBaseUrl('/api/posts'))
.then(r => r.json())
.then(setTree);
}, []);
// Load file content when selected
useEffect(() => {
if (!selectedSlug) return;
setLoading(true);
fetch(withBaseUrl(`/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(withBaseUrl(`/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(withBaseUrl(`/api/admin/posts?reparsePost=${encodeURIComponent(selectedSlug)}`));
if (!reparseResponse.ok) {
console.warn('Failed to reparse post, but file was saved');
} else {
console.log('Post saved and reparsed successfully');
}
setOriginalContent(fileContent); // Reset unsaved state after save
} catch (error) {
console.error('Error saving/reparsing post:', error);
} finally {
setSaving(false);
}
}
// Live preview (JS markdown, not Rust)
useEffect(() => {
if (!fileContent) { setPreviewHtml(""); return; }
const { content } = extractFrontmatter(fileContent);
const html = typeof marked.parse === 'function' ? marked.parse(content) : '';
if (typeof html === 'string') setPreviewHtml(html);
else if (html instanceof Promise) html.then(setPreviewHtml);
else setPreviewHtml('');
}, [fileContent]);
// Monaco Vim integration
async function handleEditorDidMount(editor: any, monaco: any) {
editorRef.current = editor;
// Ensure editor resizes properly
const resizeObserver = new ResizeObserver(() => {
editor.layout();
});
const editorContainer = editor.getContainerDomNode();
if (editorContainer) {
resizeObserver.observe(editorContainer);
}
if (vimMode) {
const { initVimMode } = await import("monaco-vim");
if (monacoVimRef.current) monacoVimRef.current.dispose();
monacoVimRef.current = initVimMode(editor, document.getElementById("vim-status"));
}
}
useEffect(() => {
if (!editorRef.current) return;
let disposed = false;
async function setupVim() {
if (monacoVimRef.current) monacoVimRef.current.dispose();
if (vimMode) {
const { initVimMode } = await import("monaco-vim");
if (!disposed) {
monacoVimRef.current = initVimMode(editorRef.current, document.getElementById("vim-status"));
}
}
}
setupVim();
return () => { disposed = true; };
}, [vimMode]);
// Split drag logic
const dragRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
function onDrag(e: React.MouseEvent | MouseEvent) {
if (!dragRef.current) return;
const percent = (e.clientX / window.innerWidth) * 100;
setSplit(percent); // No min/max limits
}
function onDragStart() {
dragRef.current = true;
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
function onDragEnd() {
dragRef.current = false;
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
useEffect(() => {
function onMove(e: MouseEvent) {
if (dragRef.current) {
onDrag(e);
}
}
function onUp() {
if (dragRef.current) {
onDragEnd();
}
}
if (isDragging) {
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}
}, [isDragging]);
// Layout logic for left pane (file browser + editor)
const leftPaneWidth = `${split}%`;
const fileBrowserWidth = 240;
// Only render MonacoEditor if the editor pane is visible and has width
const showEditor = true; // Always show editor, it will resize based on container
return (
<div className="h-screen w-screen bg-white flex flex-col font-mono" style={{ fontFamily: 'JetBrains Mono, monospace', fontWeight: 'bold' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-2">
{/* VS Code SVG Icon (smaller) */}
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
style={{ minWidth: 20, minHeight: 20, width: 24, height: 24 }}
>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fillRule="evenodd" clipRule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style={{ mixBlendMode: 'overlay' }} opacity="0.25">
<path fillRule="evenodd" clipRule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.30225 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stopColor="white"/>
<stop offset="1" stopColor="white" stopOpacity="0"/>
</linearGradient>
</defs>
</svg>
<span className="text-black text-lg font-semibold">Markdown Bearbeiter</span>
</div>
<div className="flex gap-2 items-center">
{/* Back Button */}
<button
onClick={() => handleBackNavigation()}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border font-mono text-sm sm:text-base ${
saving
? 'opacity-60 cursor-wait border-red-400 bg-red-500 text-white'
: hasUnsavedChanges
? 'border-orange-400 bg-orange-500 text-white hover:bg-orange-600'
: 'border-red-400 bg-red-500 text-white hover:bg-red-600'
}`}
disabled={saving}
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span className="hidden sm:inline">
{hasUnsavedChanges ? 'Zurück*' : 'Zurück'}
</span>
</button>
{/* Save Button */}
<button
onClick={handleSave}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border font-mono text-sm sm:text-base ${
saving
? 'opacity-60 cursor-wait border-blue-400 bg-blue-500 text-white'
: hasUnsavedChanges
? 'border-orange-400 bg-orange-500 text-white hover:bg-orange-600'
: 'border-blue-400 bg-blue-500 text-white hover:bg-blue-600'
}`}
disabled={saving}
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H7a2 2 0 01-2-2V7a2 2 0 012-2h4a2 2 0 012 2v1" /></svg>
<span className="hidden sm:inline">
{saving
? 'Am Speichern...'
: hasUnsavedChanges
? 'Speichern*'
: 'Speichern'
}
</span>
</button>
{/* Vim Mode Button */}
<button
onClick={() => setVimMode((v) => !v)}
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 rounded transition-colors border border-gray-300 text-sm sm:text-base ${
vimMode
? "bg-green-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-100"
}`}
>
{/* Actual Vim SVG Icon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor">
<title>vim</title>
<path d="M26.445 22.095l0.592-0.649h1.667l0.386 0.519-1.581 5.132h0.616l-0.1 0.261h-2.228l1.405-4.454h-2.518l-1.346 4.238h0.53l-0.091 0.217h-2.006l1.383-4.434h-2.619l-1.327 4.172h0.545l-0.090 0.261h-2.076l1.892-5.573h-0.732l0.114-0.339h2.062l0.649 0.671h1.132l0.614-0.692h1.326l0.611 0.669zM7.99 27.033h-2.141l-0.327-0.187v-21.979h-1.545l-0.125-0.125v-1.47l0.179-0.192h9.211l0.266 0.267v1.385l-0.177 0.216h-1.348v10.857l11.006-10.857h-2.607l-0.219-0.235v-1.453l0.151-0.139h9.36l0.165 0.166v1.337l-12.615 12.937h-0.466c-0.005-0-0.011-0-0.018-0-0.012 0-0.024 0.001-0.036 0.002l0.002-0-0.025 0.004c-0.058 0.012-0.108 0.039-0.149 0.075l0-0-0.429 0.369-0.005 0.004c-0.040 0.037-0.072 0.084-0.090 0.136l-0.001 0.002-0.37 1.037zM17.916 18.028l0.187 0.189-0.336 1.152-0.281 0.282h-1.211l-0.226-0.226 0.389-1.088 0.36-0.309zM13.298 27.42l1.973-5.635h-0.626l0.371-0.38h2.073l-1.953 5.692h0.779l-0.099 0.322zM30.996 15.982h-0.034l-5.396-5.396 5.377-5.516v-2.24l-0.811-0.81h-10.245l-0.825 0.756v1.306l-3.044-3.044v-0.034l-0.019 0.018-0.018-0.018v0.034l-1.612 1.613-0.672-0.673h-10.151l-0.797 0.865v2.356l0.77 0.77h0.9v6.636l-3.382 3.38h-0.034l0.018 0.016-0.018 0.017h0.034l3.382 3.382v8.081l1.133 0.654h2.902l2.321-2.379 5.206 5.206v0.035l0.019-0.017 0.017 0.017v-0.035l3.136-3.135h0.606c0.144-0.001 0.266-0.093 0.312-0.221l0.001-0.002 0.182-0.532c0.011-0.031 0.017-0.067 0.017-0.105 0-0.073-0.024-0.14-0.064-0.195l0.001 0.001 1.827-1.827-0.765 2.452c-0.009 0.029-0.015 0.063-0.015 0.097 0 0.149 0.098 0.275 0.233 0.317l0.002 0.001c0.029 0.009 0.063 0.015 0.097 0.015 0 0 0 0 0 0h2.279c0.136-0.001 0.252-0.084 0.303-0.201l0.001-0.002 0.206-0.492c0.014-0.036 0.022-0.077 0.022-0.121 0-0.048-0.010-0.094-0.028-0.135l0.001 0.002c-0.035-0.082-0.1-0.145-0.18-0.177l-0.002-0.001c-0.036-0.015-0.077-0.024-0.121-0.025h-0.094l1.050-3.304h1.54l-1.27 4.025c-0.009 0.029-0.015 0.063-0.015 0.097 0 0.149 0.098 0.274 0.232 0.317l0.002 0.001c0.029 0.009 0.063 0.015 0.098 0.015 0 0 0.001 0 0.001 0h2.502c0 0 0.001 0 0.001 0 0.14 0 0.26-0.087 0.308-0.21l0.001-0.002 0.205-0.535c0.013-0.034 0.020-0.073 0.020-0.114 0-0.142-0.090-0.264-0.215-0.311l-0.002-0.001c-0.034-0.013-0.073-0.021-0.114-0.021h-0.181l1.413-4.59c0.011-0.031 0.017-0.066 0.017-0.103 0-0.074-0.025-0.143-0.066-0.198l0.001 0.001-0.469-0.63-0.004-0.006c-0.061-0.078-0.156-0.127-0.261-0.127h-1.795c-0.093 0-0.177 0.039-0.237 0.101l-0 0-0.5 0.549h-0.78l-0.052-0.057 5.555-5.555h0.035l-0.017-0.014z"/>
</svg>
<span className="hidden sm:inline">Vim Modus</span>
</button>
</div>
</div>
{/* Split Layout */}
<div className="flex flex-1 min-h-0" style={{ userSelect: isDragging ? "none" : undefined }}>
{/* Left: File browser + Editor */}
<div className="flex flex-row h-full bg-white" style={{ width: leftPaneWidth, minWidth: 0, maxWidth: '100%' }}>
{/* File Browser Collapsible Toggle */}
<div style={{ width: 32, minWidth: 32, maxWidth: 32, display: 'flex', flexDirection: 'column' }}>
<button
className={`w-full flex items-center justify-center px-2 py-1 font-mono hover:bg-gray-200 focus:outline-none ${
browserOpen
? 'bg-gray-100 border-b border-gray-200 text-gray-700'
: 'bg-gray-200 border-b border-gray-300 text-gray-600 hover:bg-gray-300'
}`}
onClick={() => setBrowserOpen(o => !o)}
style={{ fontFamily: 'JetBrains Mono, monospace', height: 40 }}
title={browserOpen ? "Datei-Explorer ausblenden" : "Datei-Explorer anzeigen"}
>
<span className="text-lg" style={{ transform: browserOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}>
</span>
</button>
</div>
{/* File browser content */}
{browserOpen && (
<div className="border-r border-gray-200 bg-gray-50 text-gray-800 font-mono overflow-auto" style={{ width: fileBrowserWidth, minWidth: fileBrowserWidth, maxWidth: fileBrowserWidth }}>
<div className="h-64 p-2">
{tree.length === 0 ? (
<div className="text-xs text-gray-400">Keine Datein gefunden.</div>
) : (
<FileTree nodes={tree} onSelect={setSelectedSlug} selectedSlug={selectedSlug} />
)}
</div>
</div>
)}
{/* Monaco Editor */}
<div className="flex-1 p-0 overflow-auto" style={{ fontFamily: 'JetBrains Mono, monospace', fontWeight: 'bold' }}>
<div className="h-full">
{showEditor && (
<MonacoErrorBoundary>
<MonacoEditor
height="100%"
defaultLanguage="markdown"
value={fileContent}
theme="light"
options={{
fontFamily: 'JetBrains Mono',
fontWeight: 'bold',
fontSize: 15,
minimap: { enabled: true },
wordWrap: "on",
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
lineNumbers: "on",
renderLineHighlight: "all",
scrollbar: { vertical: "auto", horizontal: "auto" },
tabSize: 2,
cursorBlinking: "smooth",
cursorStyle: "line",
fixedOverflowWidgets: true,
readOnly: loading,
folding: true,
foldingStrategy: "indentation",
showFoldingControls: "always",
foldingHighlight: true,
foldingImportsByDefault: true,
unfoldOnClickAfterEndOfLine: false,
links: true,
colorDecorators: true,
formatOnPaste: true,
formatOnType: true,
}}
onChange={v => setFileContent(v ?? "")}
onMount={handleEditorDidMount}
/>
</MonacoErrorBoundary>
)}
<div id="vim-status" className="text-xs text-gray-500 px-2 py-1 bg-gray-100 border-t border-gray-200 font-mono" />
</div>
</div>
</div>
{/* Draggable Splitter - always show */}
<div
className={`w-1 cursor-col-resize transition-colors relative ${
isDragging ? 'bg-blue-500' : 'bg-gray-300 hover:bg-gray-400'
}`}
onMouseDown={onDragStart}
>
{/* Drag handle indicator */}
<div className="absolute inset-y-0 left-1/2 transform -translate-x-1/2 w-4 flex items-center justify-center">
<div className="w-1 h-8 bg-gray-400 rounded-full opacity-50"></div>
</div>
</div>
{/* Right: Live Preview */}
<div className="flex-1 bg-gray-50 p-8 overflow-auto border-l border-gray-200">
<article className="bg-white rounded-lg shadow-sm border p-6 sm:p-8">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
{fileTitle}
</h1>
</header>
<div
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 prose-code:text-gray-800 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-900 prose-pre:text-gray-100"
style={{ fontFamily: 'inherit', fontWeight: 'normal' }}
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</article>
</div>
</div>
{/* Unsaved Changes Dialog */}
{showUnsavedDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Ungespeicherte Änderungen</h3>
</div>
<p className="text-gray-600 mb-6">
Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern, bevor Sie fortfahren?
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => handleUnsavedDialogAction('cancel')}
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"
>
Abbrechen
</button>
<button
onClick={() => handleUnsavedDialogAction('discard')}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Verwerfen
</button>
<button
onClick={() => handleUnsavedDialogAction('save')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Speichern
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ErrorBoundary component for Monaco
class MonacoErrorBoundary extends React.Component<{children: React.ReactNode}, {error: Error | null}> {
constructor(props: any) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: any) {
// Optionally log error
}
render() {
if (this.state.error) {
return <div className="text-red-600 p-4">Fehler: {this.state.error.message}</div>;
}
return this.props.children;
}
}

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { withBaseUrl } from '@/lib/baseUrl';
interface Post { interface Post {
type: 'post'; type: 'post';
@@ -26,7 +27,7 @@ type Node = Post | Folder;
// Helper to get folder details // Helper to get folder details
async function getFolderDetails(path: string): Promise<{ created: string, items: number, size: number, error?: string }> { async function getFolderDetails(path: string): Promise<{ created: string, items: number, size: number, error?: string }> {
try { try {
const res = await fetch(`/api/admin/folders/details?path=${encodeURIComponent(path)}`); const res = await fetch(withBaseUrl(`/api/admin/folders/details?path=${encodeURIComponent(path)}`));
if (!res.ok) throw new Error('API error'); if (!res.ok) throw new Error('API error');
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
@@ -38,7 +39,7 @@ async function getFolderDetails(path: string): Promise<{ created: string, items:
// Helper to get post size and creation date // Helper to get post size and creation date
async function getPostSize(slug: string): Promise<{ size: number | null, created: string | null }> { async function getPostSize(slug: string): Promise<{ size: number | null, created: string | null }> {
try { try {
const res = await fetch(`/api/admin/posts/size?slug=${encodeURIComponent(slug)}`); const res = await fetch(withBaseUrl(`/api/admin/posts/size?slug=${encodeURIComponent(slug)}`));
if (!res.ok) throw new Error('API error'); if (!res.ok) throw new Error('API error');
const data = await res.json(); const data = await res.json();
return { size: data.size, created: data.created }; return { size: data.size, created: data.created };
@@ -76,7 +77,7 @@ export default function ManagePage() {
const loadContent = async () => { const loadContent = async () => {
try { try {
const response = await fetch('/api/posts'); const response = await fetch(withBaseUrl('/api/posts'));
const data = await response.json(); const data = await response.json();
setNodes(data); setNodes(data);
} catch (error) { } catch (error) {
@@ -87,7 +88,7 @@ export default function ManagePage() {
const handleLogout = () => { const handleLogout = () => {
setIsAuthenticated(false); setIsAuthenticated(false);
localStorage.removeItem('adminAuth'); localStorage.removeItem('adminAuth');
router.push('/admin'); router.push('/');
}; };
// Get current directory contents // Get current directory contents
@@ -110,7 +111,7 @@ export default function ManagePage() {
// Breadcrumbs // Breadcrumbs
const breadcrumbs = [ const breadcrumbs = [
{ name: 'Root', path: [] }, { name: '/', path: [] },
...currentPath.map((name, idx) => ({ ...currentPath.map((name, idx) => ({
name, name,
path: currentPath.slice(0, idx + 1), path: currentPath.slice(0, idx + 1),
@@ -140,7 +141,7 @@ export default function ManagePage() {
type: deleteConfirm.item.type type: deleteConfirm.item.type
}); });
const response = await fetch('/api/admin/delete', { const response = await fetch(withBaseUrl('/api/admin/delete'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -170,7 +171,7 @@ export default function ManagePage() {
// Move post API call // Move post API call
const movePost = async (post: Post, targetFolder: string[]) => { const movePost = async (post: Post, targetFolder: string[]) => {
try { try {
const response = await fetch('/api/admin/posts/move', { const response = await fetch(withBaseUrl('/api/admin/posts/move'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -238,39 +239,29 @@ export default function ManagePage() {
{/* Mobile-friendly header */} {/* Mobile-friendly header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Manage Content</h1> <h1 className="text-2xl sm:text-3xl font-bold">Inhaltsverwaltung</h1>
<Link <a
href="/admin" href={withBaseUrl('/admin')}
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-base font-medium" className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-base font-medium"
> >
Back to Admin Zum Admin-Panel
</Link> </a>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={loadContent} onClick={loadContent}
className="px-4 py-3 sm:py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-base font-medium" className="px-4 py-3 sm:py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-base font-medium"
title="Refresh content" title="Inhalt aktualisieren"
> >
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
</button> </button>
<Link
href="/admin/manage/rust-status"
className="px-4 py-3 sm:py-2 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors text-base font-medium flex items-center"
title="Rust Parser Status"
>
<svg className="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
Rust Parser Status
</Link>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium" className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
> >
Logout Abmelden
</button> </button>
</div> </div>
</div> </div>
@@ -281,7 +272,7 @@ export default function ManagePage() {
<button <button
onClick={() => setCurrentPath(currentPath.slice(0, -1))} onClick={() => setCurrentPath(currentPath.slice(0, -1))}
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-sm sm:text-base" className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-sm sm:text-base"
title="Go back one level" title="Einen Ordner zurück"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -295,7 +286,7 @@ export default function ManagePage() {
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
Back Zurück
</button> </button>
)} )}
<div className="flex flex-wrap items-center gap-1 sm:gap-2"> <div className="flex flex-wrap items-center gap-1 sm:gap-2">
@@ -430,20 +421,20 @@ export default function ManagePage() {
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full"> <div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3> <h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="mb-4 text-sm sm:text-base"> <p className="mb-4 text-sm sm:text-base">
Are you sure you want to delete {deleteConfirm.item?.type === 'folder' ? 'folder' : 'post'} "{deleteConfirm.item?.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item?.title}"? Sind Sie sicher, dass Sie {deleteConfirm.item?.type === 'folder' ? 'Ordner' : 'Beitrag'} "{deleteConfirm.item?.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item?.title}" löschen möchten?
</p> </p>
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
<button <button
onClick={() => setDeleteConfirm({ show: false, item: null })} onClick={() => setDeleteConfirm({ show: false, item: null })}
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium" className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
> >
Cancel Abbrechen
</button> </button>
<button <button
onClick={confirmDelete} onClick={confirmDelete}
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium" className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
> >
Delete Löschen
</button> </button>
</div> </div>
</div> </div>
@@ -454,9 +445,9 @@ export default function ManagePage() {
{deleteAllConfirm.show && ( {deleteAllConfirm.show && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full"> <div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
<h3 className="text-lg font-bold mb-4">Delete Full Folder</h3> <h3 className="text-lg font-bold mb-4">Lösche Ordner</h3>
<p className="mb-4 text-sm sm:text-base"> <p className="mb-4 text-sm sm:text-base">
Are you sure you want to <b>delete the entire folder and all its contents</b>? Sind Sie sicher, dass Sie <b>den gesamten Ordner und alle Inhalte löschen</b> möchten?
<br /> <br />
<span className="text-red-600">This cannot be undone!</span> <span className="text-red-600">This cannot be undone!</span>
</p> </p>
@@ -465,13 +456,13 @@ export default function ManagePage() {
onClick={() => setDeleteAllConfirm({ show: false, folder: null })} onClick={() => setDeleteAllConfirm({ show: false, folder: null })}
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium" className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
> >
Cancel Abbrechen
</button> </button>
<button <button
onClick={async () => { onClick={async () => {
if (!deleteAllConfirm.folder) return; if (!deleteAllConfirm.folder) return;
// Call delete API with recursive flag // Call delete API with recursive flag
await fetch('/api/admin/delete', { await fetch(withBaseUrl('/api/admin/delete'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -486,7 +477,7 @@ export default function ManagePage() {
}} }}
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium" className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
> >
Delete All Löschen
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,74 +0,0 @@
import React, { useEffect, useState } from 'react';
interface PostStats {
slug: string;
cache_hits: number;
cache_misses: number;
last_interpret_time_ms: number;
last_compile_time_ms: number;
}
export default function RustStatusPage() {
const [stats, setStats] = useState<PostStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/posts?rsparseinfo=1');
if (!res.ok) throw new Error('Failed to fetch stats');
const data = await res.json();
setStats(data);
} catch (e: any) {
setError(e.message || 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 5000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Rust Parser Status</h1>
{loading && <div>Loading...</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && (
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-300 bg-white shadow-md rounded">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2 text-left">Slug</th>
<th className="px-4 py-2 text-right">Cache Hits</th>
<th className="px-4 py-2 text-right">Cache Misses</th>
<th className="px-4 py-2 text-right">Last Interpret Time (ms)</th>
<th className="px-4 py-2 text-right">Last Compile Time (ms)</th>
</tr>
</thead>
<tbody>
{stats.length === 0 ? (
<tr><td colSpan={5} className="text-center py-4">No stats available.</td></tr>
) : (
stats.map(stat => (
<tr key={stat.slug} className="border-t">
<td className="px-4 py-2 font-mono">{stat.slug}</td>
<td className="px-4 py-2 text-right">{stat.cache_hits}</td>
<td className="px-4 py-2 text-right">{stat.cache_misses}</td>
<td className="px-4 py-2 text-right">{stat.last_interpret_time_ms}</td>
<td className="px-4 py-2 text-right">{stat.last_compile_time_ms}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -1,18 +1,6 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Bar } from 'react-chartjs-2'; import { withBaseUrl } from '@/lib/baseUrl';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
ChartOptions,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
interface PostStats { interface PostStats {
slug: string; slug: string;
@@ -22,180 +10,512 @@ interface PostStats {
last_compile_time_ms: number; last_compile_time_ms: number;
} }
interface HealthReport {
posts_dir_exists: boolean;
posts_count: number;
cache_file_exists: boolean;
cache_stats_file_exists: boolean;
cache_readable: boolean;
cache_stats_readable: boolean;
cache_post_count?: number;
cache_stats_count?: number;
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);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false); const [health, setHealth] = useState<HealthReport | null>(null);
const autoRefreshRef = React.useRef<NodeJS.Timeout | null>(null); const [healthLoading, setHealthLoading] = useState(true);
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>('');
const fetchStats = async () => { // Summary calculations
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/posts?rsparseinfo=1');
if (!res.ok) throw new Error('Failed to fetch stats');
const data = await res.json();
setStats(data);
} catch (e: any) {
setError(e.message || 'Unknown error');
} finally {
setLoading(false);
}
};
React.useEffect(() => {
fetchStats();
// Listen for post changes via BroadcastChannel
let bc: BroadcastChannel | null = null;
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
bc = new BroadcastChannel('posts-changed');
bc.onmessage = (event) => {
if (event.data === 'changed') {
fetchStats();
}
};
}
return () => {
if (bc) bc.close();
if (autoRefreshRef.current) clearInterval(autoRefreshRef.current);
};
}, []);
// Handle auto-refresh toggle
React.useEffect(() => {
if (autoRefresh) {
autoRefreshRef.current = setInterval(fetchStats, 2000);
} else if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
autoRefreshRef.current = null;
}
return () => {
if (autoRefreshRef.current) clearInterval(autoRefreshRef.current);
};
}, [autoRefresh]);
// Dashboard summary calculations
const totalHits = stats.reduce((sum, s) => sum + s.cache_hits, 0); const totalHits = stats.reduce((sum, s) => sum + s.cache_hits, 0);
const totalMisses = stats.reduce((sum, s) => sum + s.cache_misses, 0); const totalMisses = stats.reduce((sum, s) => sum + s.cache_misses, 0);
const avgInterpret = stats.length ? (stats.reduce((sum, s) => sum + s.last_interpret_time_ms, 0) / stats.length).toFixed(1) : 0; const avgInterpret = stats.length ? (stats.reduce((sum, s) => sum + s.last_interpret_time_ms, 0) / stats.length).toFixed(1) : 0;
const avgCompile = stats.length ? (stats.reduce((sum, s) => sum + s.last_compile_time_ms, 0) / stats.length).toFixed(1) : 0; const avgCompile = stats.length ? (stats.reduce((sum, s) => sum + s.last_compile_time_ms, 0) / stats.length).toFixed(1) : 0;
// Chart data const fetchStats = async () => {
const chartData = { setLoading(true);
labels: stats.map(s => s.slug), setError(null);
datasets: [ try {
{ const res = await fetch(withBaseUrl('/api/admin/posts?rsparseinfo=1'));
label: 'Cache Hits', if (!res.ok) throw new Error('Fehler beim Laden der Statistiken');
data: stats.map(s => s.cache_hits), const data = await res.json();
backgroundColor: 'rgba(34,197,94,0.7)', setStats(data);
}, } catch (e: any) {
{ setError(e.message || 'Unbekannter Fehler');
label: 'Cache Misses', } finally {
data: stats.map(s => s.cache_misses), setLoading(false);
backgroundColor: 'rgba(239,68,68,0.7)', }
},
],
}; };
const chartOptions: ChartOptions<'bar'> = {
responsive: true, const fetchHealth = async () => {
plugins: { setHealthLoading(true);
legend: { position: 'top' }, setHealthError(null);
title: { display: true, text: 'Cache Hits & Misses per Post' }, try {
}, const res = await fetch(withBaseUrl('/api/admin/posts?checkhealth=1'));
scales: { if (!res.ok) throw new Error('Fehler beim Laden des Health-Checks');
x: { stacked: true }, const data = await res.json();
y: { stacked: true, beginAtZero: true }, setHealth(data);
}, } catch (e: any) {
setHealthError(e.message || 'Unbekannter Fehler');
} finally {
setHealthLoading(false);
}
};
const fetchLogs = async () => {
setLogsLoading(true);
setLogsError(null);
try {
const res = await fetch(withBaseUrl('/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(withBaseUrl('/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(withBaseUrl('/api/admin/posts?reinterpretAll=1'));
if (!res.ok) throw new Error('Fehler beim Neuinterpretieren der Posts');
const data = await res.json();
console.log('Neu-Interpretier Ergebins:', data);
// Refresh all data after reinterpret
await Promise.all([fetchStats(), fetchHealth(), fetchLogs()]);
} catch (e: any) {
console.error('Fehler beim Neu-Interpretieren => ', e);
}
};
useEffect(() => {
fetchStats();
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 (
<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 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 ( return (
<div className="p-8 max-w-6xl mx-auto"> <div className="min-h-screen bg-gray-100 flex items-center justify-center p-4 sm:p-6">
<h1 className="text-3xl font-bold mb-8 text-center">Rust Parser Dashboard</h1> <div className="w-full max-w-6xl">
<div className="flex justify-end gap-4 mb-4"> {/* Header with title and action buttons */}
<button <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-4">
onClick={fetchStats} <div className="flex items-center gap-2">
className="px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700" <div className="bg-white rounded-lg shadow-sm p-1.5 flex items-center justify-center">
> <img
Refresh className="w-8 h-8 sm:w-10 sm:h-10"
</button> src="https://upload.wikimedia.org/wikipedia/commons/d/d5/Rust_programming_language_black_logo.svg"
<label className="flex items-center gap-2 cursor-pointer"> alt="Rust Logo"
<input />
type="checkbox" </div>
checked={autoRefresh} <h1 className="text-lg sm:text-xl font-bold">Rust-Parser Statistiken</h1>
onChange={e => setAutoRefresh(e.target.checked)} </div>
className="form-checkbox"
/> <div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<span className="text-sm">Auto-refresh every 2s</span> {/* Back to Admin button */}
</label> <a
</div> href={withBaseUrl('/admin')}
{loading && ( 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"
<div className="flex flex-col items-center justify-center h-64"> title="Zurück zur Admin-Panel"
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mb-4"></div> >
<div className="text-lg">Loading stats...</div> <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" />
</svg>
<span className="hidden sm:inline">Zurück</span>
</a>
{/* Refresh button */}
<button
onClick={() => {
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"
disabled={loading || healthLoading || logsLoading}
>
<svg
className={`w-4 h-4 ${(loading || healthLoading || logsLoading) ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="hidden sm:inline">Aktualisieren</span>
</button>
</div>
</div> </div>
)}
{error && ( {/* Health Check Section */}
<div className="text-red-500 text-center text-lg">{error}</div> <div className="mb-4">
)} <h2 className="text-sm sm:text-base font-semibold mb-2 text-center">Health-Check</h2>
{!loading && !error && ( {healthLoading && <div className="text-center py-3 text-sm">Lade Health-Check...</div>}
<> {healthError && <div className="text-red-500 text-center text-sm">{healthError}</div>}
{/* Summary Cards */} {health && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <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="bg-green-100 rounded-lg p-6 flex flex-col items-center shadow"> <div className="flex flex-wrap gap-4 justify-center">
<span className="text-2xl font-bold text-green-700">{totalHits}</span> <div className="flex flex-col items-center">
<span className="text-gray-700 mt-2">Total Cache Hits</span> <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
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 className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
<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 className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
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 className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
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 className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
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 className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
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>
{typeof health.cache_post_count === 'number' && (
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
<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>
)}
{typeof health.cache_stats_count === 'number' && (
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center">
<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>
{health.errors.length > 0 && (
<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>
<ul className="list-disc ml-5 inline-block text-left">
{health.errors.map((err, i) => <li key={i}>{err}</li>)}
</ul>
</div>
)}
</div> </div>
<div className="bg-red-100 rounded-lg p-6 flex flex-col items-center shadow"> )}
<span className="text-2xl font-bold text-red-700">{totalMisses}</span> </div>
<span className="text-gray-700 mt-2">Total Cache Misses</span>
</div> {/* Summary Cards */}
<div className="bg-blue-100 rounded-lg p-6 flex flex-col items-center shadow"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-4">
<span className="text-2xl font-bold text-blue-700">{avgInterpret} ms</span> <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-gray-700 mt-2">Avg Interpret Time</span> <span className="text-lg sm:text-xl font-bold text-green-700">{totalHits}</span>
</div> <span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Cache-Treffer</span>
<div className="bg-purple-100 rounded-lg p-6 flex flex-col items-center shadow"> </div>
<span className="text-2xl font-bold text-purple-700">{avgCompile} ms</span> <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-gray-700 mt-2">Avg Compile Time</span> <span className="text-lg sm:text-xl font-bold text-red-700">{totalMisses}</span>
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Cache-Fehlschläge</span>
</div>
<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-lg sm:text-xl font-bold text-blue-700">{avgInterpret} ms</span>
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Ø Interpretationszeit</span>
</div>
<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-lg sm:text-xl font-bold text-purple-700">{avgCompile} ms</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="Neuinterpretation aller Beiträge erzwingen"
>
Alle Posts neu Interpretieren?
</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="Logs Leeren"
>
Logs Leeren
</button>
</div> </div>
</div> </div>
{/* Bar Chart */} {/* Log Filters */}
<div className="bg-white rounded-lg shadow p-6 mb-10"> <div className="flex flex-col sm:flex-row gap-3 mb-3">
<Bar data={chartData} options={chartOptions} height={120} /> <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">Alle Stufen</option>
<option value="info">Info</option>
<option value="warning">Warnungen</option>
<option value="error">Fehler</option>
</select>
</div>
</div> </div>
{/* Raw Data Table */} {/* Logs Display */}
<div className="overflow-x-auto"> <div className="max-h-80 overflow-y-auto">
<table className="min-w-full border border-gray-300 bg-white shadow-md rounded"> {logsLoading && <div className="text-center py-3 text-sm">Loading logs...</div>}
<thead> {logsError && <div className="text-red-500 text-center py-3 text-sm">{logsError}</div>}
<tr className="bg-gray-100"> {!logsLoading && !logsError && (
<th className="px-4 py-2 text-left">Slug</th> <div className="space-y-2">
<th className="px-4 py-2 text-right">Cache Hits</th> {filteredLogs.length === 0 ? (
<th className="px-4 py-2 text-right">Cache Misses</th> <div className="text-center py-3 text-gray-500 text-sm">No logs found</div>
<th className="px-4 py-2 text-right">Last Interpret Time (ms)</th>
<th className="px-4 py-2 text-right">Last Compile Time (ms)</th>
</tr>
</thead>
<tbody>
{stats.length === 0 ? (
<tr><td colSpan={5} className="text-center py-4">No stats available.</td></tr>
) : ( ) : (
stats.map(stat => ( filteredLogs.map((log, index) => (
<tr key={stat.slug} className="border-t"> <div key={index} className={`p-3 rounded-lg border-l-4 shadow-sm ${
<td className="px-4 py-2 font-mono">{stat.slug}</td> log.level === 'error' ? 'bg-gradient-to-r from-red-50 to-red-100 border-red-400' :
<td className="px-4 py-2 text-right">{stat.cache_hits}</td> log.level === 'warning' ? 'bg-gradient-to-r from-yellow-50 to-yellow-100 border-yellow-400' :
<td className="px-4 py-2 text-right">{stat.cache_misses}</td> 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-400'
<td className="px-4 py-2 text-right">{stat.last_interpret_time_ms}</td> }`}>
<td className="px-4 py-2 text-right">{stat.last_compile_time_ms}</td> <div className="flex items-start gap-2">
</tr> <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>
)) ))
)} )}
</tbody> </div>
</table> )}
</div> </div>
</> </div>
)}
{/* Table */}
<div className="bg-white rounded-lg shadow-sm p-3 sm:p-4 overflow-x-auto">
<h2 className="text-sm sm:text-base font-semibold mb-2">Rohdaten</h2>
{loading && <div className="text-center py-4 text-sm">Lade Statistiken...</div>}
{error && <div className="text-red-500 text-center text-sm">{error}</div>}
{!loading && !error && (
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 bg-white rounded text-sm">
<thead>
<tr className="bg-gray-100">
<th className="px-2 py-1.5 text-left text-xs">Slug</th>
<th className="px-2 py-1.5 text-right text-xs">Cache-Treffer</th>
<th className="px-2 py-1.5 text-right text-xs">Cache-Fehlschläge</th>
<th className="px-2 py-1.5 text-right text-xs">Interpret (ms)</th>
<th className="px-2 py-1.5 text-right text-xs">Kompilier (ms)</th>
</tr>
</thead>
<tbody>
{stats.length === 0 ? (
<tr><td colSpan={5} className="text-center py-2 text-xs">Keine Statistiken verfügbar.</td></tr>
) : (
stats.map(stat => (
<tr key={stat.slug} className="border-t border-gray-200">
<td className="px-2 py-1.5 font-mono text-xs">{stat.slug}</td>
<td className="px-2 py-1.5 text-right text-xs">{stat.cache_hits}</td>
<td className="px-2 py-1.5 text-right text-xs">{stat.cache_misses}</td>
<td className="px-2 py-1.5 text-right text-xs">{stat.last_interpret_time_ms}</td>
<td className="px-2 py-1.5 text-right text-xs">{stat.last_compile_time_ms}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -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,22 @@ 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';
import { withBaseUrl } from '@/lib/baseUrl';
const MonacoEditor = dynamicImport(() => import('./MonacoEditor'), { ssr: false });
// Import monaco-vim only on client side
let initVimMode: any = null;
let VimMode: any = null;
if (typeof window !== 'undefined') {
const monacoVim = require('monaco-vim');
initVimMode = monacoVim.initVimMode;
VimMode = monacoVim.VimMode;
}
import '@fontsource/jetbrains-mono';
interface Post { interface Post {
slug: string; slug: string;
@@ -46,7 +68,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 +102,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 +112,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 +120,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,20 +156,14 @@ 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]);
useEffect(() => { useEffect(() => {
// Check if docker is used // Check if docker is used
fetch('/api/admin/docker') fetch(withBaseUrl('/api/admin/docker'))
.then(res => res.json()) .then(res => res.json())
.then(data => setIsDocker(!!data.docker)) .then(data => setIsDocker(!!data.docker))
.catch(() => setIsDocker(false)); .catch(() => setIsDocker(false));
@@ -150,7 +171,7 @@ export default function AdminPage() {
const loadContent = async () => { const loadContent = async () => {
try { try {
const response = await fetch('/api/posts'); const response = await fetch(withBaseUrl('/api/posts'));
const data = await response.json(); const data = await response.json();
setNodes(data); setNodes(data);
} catch (error) { } catch (error) {
@@ -169,7 +190,7 @@ export default function AdminPage() {
return; return;
} }
// Check password via API // Check password via API
const res = await fetch('/api/admin/password', { const res = await fetch(withBaseUrl('/api/admin/password'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pass, mode: 'login' }), body: JSON.stringify({ password: pass, mode: 'login' }),
@@ -193,7 +214,7 @@ export default function AdminPage() {
const handleCreatePost = async (e: React.FormEvent) => { const handleCreatePost = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await fetch('/api/admin/posts', { const response = await fetch(withBaseUrl('/api/admin/posts'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -231,7 +252,7 @@ export default function AdminPage() {
} }
try { try {
const response = await fetch('/api/admin/folders', { const response = await fetch(withBaseUrl('/api/admin/folders'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -327,7 +348,7 @@ export default function AdminPage() {
formData.append('file', file); formData.append('file', file);
formData.append('path', currentPath.join('/')); formData.append('path', currentPath.join('/'));
const response = await fetch('/api/admin/upload', { const response = await fetch(withBaseUrl('/api/admin/upload'), {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@@ -361,7 +382,7 @@ export default function AdminPage() {
type: deleteConfirm.item.type type: deleteConfirm.item.type
}); });
const response = await fetch('/api/admin/delete', { const response = await fetch(withBaseUrl('/api/admin/delete'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -394,7 +415,7 @@ export default function AdminPage() {
? prev.filter((s) => s !== slug) ? prev.filter((s) => s !== slug)
: [slug, ...prev]; : [slug, ...prev];
// Update pinned.json on the server // Update pinned.json on the server
fetch('/api/admin/posts', { fetch(withBaseUrl('/api/admin/posts'), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: newPinned }), body: JSON.stringify({ pinned: newPinned }),
@@ -433,7 +454,7 @@ export default function AdminPage() {
return; return;
} }
// Check old password // Check old password
const res = await fetch('/api/admin/password', { const res = await fetch(withBaseUrl('/api/admin/password'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: changePwOld, mode: 'login' }), body: JSON.stringify({ password: changePwOld, mode: 'login' }),
@@ -444,7 +465,7 @@ export default function AdminPage() {
return; return;
} }
// Set new password // Set new password
const res2 = await fetch('/api/admin/password', { const res2 = await fetch(withBaseUrl('/api/admin/password'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: changePwNew }), body: JSON.stringify({ password: changePwNew }),
@@ -464,7 +485,7 @@ export default function AdminPage() {
// Function to load a post's raw markdown // Function to load a post's raw markdown
const loadPostRaw = async (slug: string, folderPath: string) => { const loadPostRaw = async (slug: string, folderPath: string) => {
const params = new URLSearchParams({ slug, path: folderPath }); const params = new URLSearchParams({ slug, path: folderPath });
const res = await fetch(`/api/admin/posts/raw?${params.toString()}`); const res = await fetch(withBaseUrl(`/api/admin/posts/raw?${params.toString()}`));
if (!res.ok) { if (!res.ok) {
alert('Error loading post'); alert('Error loading post');
return; return;
@@ -496,7 +517,7 @@ export default function AdminPage() {
summary: newPost.summary, summary: newPost.summary,
author: process.env.NEXT_PUBLIC_BLOG_OWNER + "'s" || 'Anonymous', author: process.env.NEXT_PUBLIC_BLOG_OWNER + "'s" || 'Anonymous',
}); });
const response = await fetch('/api/admin/posts', { const response = await fetch(withBaseUrl('/api/admin/posts'), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -518,15 +539,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');
@@ -594,9 +607,9 @@ export default function AdminPage() {
} }
closeModal(); closeModal();
if (choice === 'docker') { if (choice === 'docker') {
exportFromEndpoint('/api/admin/export'); exportFromEndpoint(withBaseUrl('/api/admin/export'));
} else if (choice === 'local') { } else if (choice === 'local') {
exportFromEndpoint('/api/admin/exportlocal'); exportFromEndpoint(withBaseUrl('/api/admin/exportlocal'));
} }
}; };
@@ -619,6 +632,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 +656,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 {
@@ -671,7 +694,7 @@ export default function AdminPage() {
// Save to JSON file in background // Save to JSON file in background
try { try {
console.log('Fetching current pinned data...'); console.log('Fetching current pinned data...');
const pinnedRes = await fetch('/api/admin/posts', { method: 'GET' }); const pinnedRes = await fetch(withBaseUrl('/api/admin/posts'), { method: 'GET' });
if (!pinnedRes.ok) { if (!pinnedRes.ok) {
throw new Error(`Failed to fetch pinned data: ${pinnedRes.status}`); throw new Error(`Failed to fetch pinned data: ${pinnedRes.status}`);
} }
@@ -684,7 +707,7 @@ export default function AdminPage() {
console.log('Updated folderEmojis:', folderEmojis); console.log('Updated folderEmojis:', folderEmojis);
console.log('Saving to pinned.json...'); console.log('Saving to pinned.json...');
const saveRes = await fetch('/api/admin/posts', { const saveRes = await fetch(withBaseUrl('/api/admin/posts'), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folderEmojis, pinned: pinnedData.pinned || [] }), body: JSON.stringify({ folderEmojis, pinned: pinnedData.pinned || [] }),
@@ -752,6 +775,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 && (
@@ -812,31 +846,115 @@ export default function AdminPage() {
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-sm sm:text-base font-medium" className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-red-600 to-pink-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-red-700 hover:to-pink-600 transition-all focus:outline-none focus:ring-2 focus:ring-red-400"
title="Logout"
> >
Logout <svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span className="flex flex-col items-start">
<span>Abmelden</span>
<span className="text-xs font-normal text-red-100">Ausloggen</span>
</span>
</button> </button>
<button <button
onClick={() => setShowChangePassword(true)} onClick={() => setShowChangePassword(true)}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium" className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-blue-600 to-cyan-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-blue-700 hover:to-cyan-600 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
title="Passwort ändern"
> >
Passwort ändern <svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 11c1.104 0 2-.896 2-2s-.896-2-2-2-2 .896-2 2 .896 2 2 2zm6 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2v-5m12 0V9a6 6 0 10-12 0v4m12 0H6" />
</svg>
<span className="flex flex-col items-start">
<span>Passwort ändern</span>
<span className="text-xs font-normal text-blue-100">Passwort ändern</span>
</span>
</button> </button>
</div> </div>
{/* Docker warning above export button */}
{isDocker && (
<div className="px-4 py-2 bg-yellow-200 text-yellow-900 rounded border border-yellow-400 font-semibold text-xs sm:text-sm text-center">
<span className="font-bold">Warning:</span> Docker is in use. Exporting will export the entire <span className="font-mono">/app</span> root directory (including all files and folders in the container's root).
</div>
)}
<div className="flex flex-col sm:flex-row items-center gap-2"> <div className="flex flex-col sm:flex-row items-center gap-2">
<button <button
onClick={handleExportTarball} onClick={handleExportTarball}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm sm:text-base font-medium whitespace-nowrap" className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-green-600 to-emerald-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-green-700 hover:to-emerald-600 transition-all focus:outline-none focus:ring-2 focus:ring-green-400"
title="Export Docker Posts" title="Export Docker Posts"
> >
Export Posts <svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h8M12 8v8" />
</svg>
<span className="flex flex-col items-start">
<span>Exportieren</span>
<span className="text-xs font-normal text-green-100">Alle exportieren</span>
</span>
</button> </button>
<a
href={withBaseUrl('/admin/manage/rust-status')}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-teal-500 to-blue-500 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-teal-600 hover:to-blue-600 transition-all focus:outline-none focus:ring-2 focus:ring-teal-400"
title="View Rust Parser Dashboard"
style={{ minWidth: '160px' }}
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01" />
</svg>
<span className="flex flex-col items-start">
<span>Rust-Parser</span>
<span className="text-xs font-normal text-teal-100">Statistiken</span>
</span>
</a>
{/* VS Code Editor Button */}
<a
href={withBaseUrl('/admin/editor')}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-gradient-to-r from-gray-700 to-blue-700 text-white rounded-xl shadow-lg flex items-center justify-center gap-2 text-sm sm:text-base font-semibold hover:from-gray-800 hover:to-blue-800 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
title="Markdown Bearbeiter"
style={{ minWidth: '160px' }}
>
{/* VS Code SVG Icon */}
<svg width="24" height="24" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fillRule="evenodd" clipRule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style={{ mixBlendMode: 'overlay' }} opacity="0.25">
<path fillRule="evenodd" clipRule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.29775 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stopColor="white"/>
<stop offset="1" stopColor="white" stopOpacity="0"/>
</linearGradient>
</defs>
</svg>
<span className="flex flex-col items-start">
<span>Markdown Editor</span>
<span className="text-xs font-normal text-blue-100">Visual Studio Code</span>
</span>
</a>
{rememberExportChoice && lastExportChoice && ( {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>
@@ -960,27 +1078,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>
{/* Create Folder Form */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Folder name"
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base"
required
/>
<button
type="submit"
className="px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-base font-medium"
>
Create Folder
</button>
</form>
</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 ${
@@ -991,14 +1088,35 @@ export default function AdminPage() {
onDrop={handleDrop} onDrop={handleDrop}
> >
<div className="text-gray-600"> <div className="text-gray-600">
<p className="text-base sm:text-lg font-medium">Drag and drop Markdown files here</p> <p className="text-base sm:text-lg font-medium">Ziehe Markdown-Dateien hierher</p>
<p className="text-xs sm:text-sm">Files will be uploaded to: {currentPath.join('/') || 'root'}</p> <p className="text-xs sm:text-sm">Dateien werden hochgeladen zu: {currentPath.join('/') || 'root'}</p>
</div> </div>
</div> </div>
{/* Create Folder Form */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base"
required
/>
<button
type="submit"
className="px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-base font-medium"
>
Ordner erstellen
</button>
</form>
</div>
{/* Create Post Form */} {/* Create Post Form */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8"> <div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Post</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">Erstelle neuen Beitrag</h2>
<form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4"> <form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Title</label> <label className="block text-sm font-medium text-gray-700">Title</label>
@@ -1011,7 +1129,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">Date</label> <label className="block text-sm font-medium text-gray-700">Datum</label>
<input <input
type="date" type="date"
value={newPost.date} value={newPost.date}
@@ -1021,7 +1139,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">Tags (comma-separated)</label> <label className="block text-sm font-medium text-gray-700">Tags (komma-getrennt)</label>
<input <input
type="text" type="text"
value={newPost.tags} value={newPost.tags}
@@ -1031,7 +1149,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">Summary</label> <label className="block text-sm font-medium text-gray-700">Zusammenfassung</label>
<textarea <textarea
value={newPost.summary} value={newPost.summary}
onChange={(e) => setNewPost({ ...newPost, summary: e.target.value })} onChange={(e) => setNewPost({ ...newPost, summary: e.target.value })}
@@ -1040,25 +1158,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">Content (Markdown)</label> <div style={{ height: '240px' }}>
<textarea <MonacoEditor
value={newPost.content} height="100%"
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })} defaultLanguage="markdown"
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm sm:text-base" value={newPost.content}
style={{ height: '240px' }} onChange={(value?: string) => setNewPost({ ...newPost, content: value || '' })}
rows={10} options={{
required minimap: { enabled: false },
/> wordWrap: 'on',
fontSize: 14,
scrollBeyondLastLine: false,
theme: 'vs-light',
lineNumbers: 'on',
automaticLayout: true,
fontFamily: 'JetBrains Mono, monospace',
}}
onMount={(editor: any) => {
monacoRef.current = editor;
}}
/>
</div>
</div> </div>
<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">Live Preview</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>
@@ -1066,14 +1225,14 @@ export default function AdminPage() {
type="submit" type="submit"
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700" className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700"
> >
{editingPost ? 'Save Changes' : 'Create Post'} {editingPost ? 'Speichern' : 'Beitrag erstellen'}
</button> </button>
</form> </form>
</div> </div>
{/* Content List */} {/* Content List */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6"> <div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Content</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">Inhalt:</h2>
<div className="space-y-4"> <div className="space-y-4">
{/* Folders */} {/* Folders */}
{currentNodes {currentNodes
@@ -1200,7 +1359,7 @@ export default function AdminPage() {
{showManageContent && ( {showManageContent && (
<div className="mt-4 bg-white p-4 sm:p-6 rounded-lg shadow text-center"> <div className="mt-4 bg-white p-4 sm:p-6 rounded-lg shadow text-center">
<p className="text-gray-600 mb-2 text-sm sm:text-base"> <p className="text-gray-600 mb-2 text-sm sm:text-base">
Delete posts and folders, manage your content structure Lösche Beiträge und Ordner, verwalte deine Inhaltsstruktur
</p> </p>
{/* Folder navigation breadcrumbs */} {/* Folder navigation breadcrumbs */}
<div className="flex flex-wrap justify-center gap-1 sm:gap-2 mb-4"> <div className="flex flex-wrap justify-center gap-1 sm:gap-2 mb-4">
@@ -1208,7 +1367,7 @@ export default function AdminPage() {
onClick={() => setManagePath([])} onClick={() => setManagePath([])}
className={`px-2 py-1 rounded text-sm sm:text-base ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`} className={`px-2 py-1 rounded text-sm sm:text-base ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
> >
Root /
</button> </button>
{managePath.map((name, idx) => ( {managePath.map((name, idx) => (
<button <button
@@ -1319,7 +1478,7 @@ export default function AdminPage() {
</div> </div>
))} ))}
</div> </div>
<a href="/admin/manage" className="block mt-6 text-blue-600 hover:underline text-sm sm:text-base">Go to Content Manager</a> <a href={withBaseUrl('/admin/manage')} className="block mt-6 text-blue-600 hover:underline text-sm sm:text-base">Zur Inhaltsverwaltung</a>
</div> </div>
)} )}
</div> </div>

View File

@@ -70,6 +70,86 @@ export async function GET(request: Request) {
}); });
} }
} }
const checkhealth = searchParams.get('checkhealth');
if (checkhealth === '1') {
// Call the Rust backend for health check
const rustResult = spawnSync(
process.cwd() + '/markdown_backend/target/release/markdown_backend',
['checkhealth'],
{ 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 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');
@@ -131,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 });
}
}

View File

@@ -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; const rustResult = spawnSync(
try { path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'),
const rustResult = spawnSync( ['show', slugPath],
path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'), { encoding: 'utf-8' }
['show', slugPath], );
{ encoding: 'utf-8' } if (rustResult.status === 0 && rustResult.stdout) {
); const post = JSON.parse(rustResult.stdout);
if (rustResult.status === 0 && rustResult.stdout) { const fs = require('fs');
post = JSON.parse(rustResult.stdout); const filePath = path.join(postsDirectory, slugPath + '.md');
post.createdAt = post.created_at; let raw = '';
delete post.created_at; try {
parser = 'rust'; raw = fs.readFileSync(filePath, 'utf8');
} else { } catch {}
rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error'; post.raw = raw;
console.error('[Rust parser error]', rustError); post.createdAt = post.created_at;
} delete post.created_at;
} catch (e) { return NextResponse.json(post);
rustError = e instanceof Error ? e.message : String(e); } else {
console.error('[Rust parser exception]', rustError); const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 });
} }
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 });
}
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { spawnSync } from 'child_process';
import path from 'path';
export async function POST(request: Request) {
try {
const { markdown } = await request.json();
if (typeof markdown !== 'string') {
return NextResponse.json({ error: 'Invalid markdown' }, { status: 400 });
}
// Call Rust backend with 'render' command, pass markdown via stdin
const rustPath = path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend');
const rustResult = spawnSync(rustPath, ['render'], {
input: markdown,
encoding: 'utf-8',
});
if (rustResult.status === 0 && rustResult.stdout) {
return NextResponse.json({ html: rustResult.stdout });
} else {
const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 });
}
} catch (error) {
return NextResponse.json({ error: 'Error rendering markdown', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
}
}

View File

@@ -5,8 +5,7 @@ import fs from 'fs';
import path from 'path'; import 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);

View File

@@ -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,50 +8,160 @@ export const runtime = 'nodejs';
// Store connected clients // Store connected clients
const clients = new Set<ReadableStreamDefaultController>(); const clients = new Set<ReadableStreamDefaultController>();
export async function GET(request: NextRequest) { // Handle CORS preflight requests
const stream = new ReadableStream({ export async function OPTIONS(request: NextRequest) {
start(controller) { return new NextResponse(null, {
// Add this client to the set status: 200,
clients.add(controller);
// Send initial connection message
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
// Set up file watcher if not already set up
if (clients.size === 1) {
watchPosts(() => {
// Notify all connected clients about the update
const message = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() });
clients.forEach(client => {
try {
client.enqueue(`data: ${message}\n\n`);
} catch (error) {
// Remove disconnected clients
clients.delete(client);
}
});
});
}
// Clean up when client disconnects
request.signal.addEventListener('abort', () => {
clients.delete(controller);
// Stop watching if no clients are connected
if (clients.size === 0) {
stopWatching();
}
});
}
});
return new NextResponse(stream, {
headers: { headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control' '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) {
try {
const stream = new ReadableStream({
start(controller) {
// Add this client to the set
clients.add(controller);
// Send initial connection message
try {
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 Rust file watcher if not already set up
if (clients.size === 1) {
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
const updateMessage = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() });
const clientsToRemove: ReadableStreamDefaultController[] = [];
clients.forEach(client => {
try {
client.enqueue(`data: ${updateMessage}\n\n`);
} catch (error) {
// Mark client for removal
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
request.signal.addEventListener('abort', () => {
clients.delete(controller);
// Stop watching if no clients are connected
if (clients.size === 0) {
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, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'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': '*',
},
}
);
}
}

View File

@@ -444,3 +444,15 @@ select:focus {
word-break: break-word; word-break: break-word;
} }
} }
.custom-tag {
padding: 1rem;
border-radius: 6px;
margin: 1rem 0;
font-weight: 500;
}
.custom-tag.warning { background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; }
.custom-tag.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
.custom-tag.success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
.custom-tag.error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
.custom-tag.mytag { background: #e3e3ff; border: 1px solid #b3b3ff; color: #333366; }

View File

@@ -7,6 +7,7 @@ import AboutButton from './AboutButton';
import BadgeButton from './BadgeButton'; import BadgeButton from './BadgeButton';
import HeaderButtons from './HeaderButtons'; import HeaderButtons from './HeaderButtons';
import MobileNav from './MobileNav'; import MobileNav from './MobileNav';
import { withBaseUrl } from '@/lib/baseUrl';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
@@ -46,11 +47,11 @@ export default function RootLayout({
return ( return (
<html lang="de" className="h-full"> <html lang="de" className="h-full">
<Head> <Head>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href={withBaseUrl('/favicon.ico')} />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href={withBaseUrl('/apple-touch-icon.png')} />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href={withBaseUrl('/favicon-32x32.png')} />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href={withBaseUrl('/favicon-16x16.png')} />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href={withBaseUrl('/site.webmanifest')} />
</Head> </Head>
<body className={`${inter.className} h-full min-h-screen flex flex-col`}> <body className={`${inter.className} h-full min-h-screen flex flex-col`}>
<MobileNav blogOwner={blogOwner} /> <MobileNav blogOwner={blogOwner} />
@@ -71,17 +72,23 @@ export default function RootLayout({
className="h-6 sm:h-8" className="h-6 sm:h-8"
/> />
<img <img
src="https://img.shields.io/badge/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white" src="https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white"
alt="Tailwind CSS" alt="Rust"
className="h-6 sm:h-8"
className="h-6 sm:h-8"
/> />
<img <img
src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white"
alt="TypeScript" alt="TypeScript"
className="h-6 sm:h-8" className="h-6 sm:h-8"
/> />
</div> <img
src="https://img.shields.io/badge/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white"
alt="Tailwind CSS"
className="h-6 sm:h-8"
/>
</div>
<div className="hidden sm:flex gap-2 justify-center sm:justify-end"> <div className="hidden sm:flex gap-2 justify-center sm:justify-end">
<HeaderButtons /> <HeaderButtons />
</div> </div>

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

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

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { format } from 'date-fns'; import { format } from 'date-fns';
import React from 'react'; import React from 'react';
import { withBaseUrl } from '@/lib/baseUrl';
interface Post { interface Post {
type: 'post'; type: 'post';
@@ -47,7 +48,7 @@ export default function Home() {
const setupSSE = () => { const setupSSE = () => {
try { try {
eventSource = new EventSource('/api/posts/stream'); eventSource = new EventSource(withBaseUrl('/api/posts/stream'));
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
@@ -101,7 +102,7 @@ export default function Home() {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const response = await fetch('/api/posts'); const response = await fetch(withBaseUrl('/api/posts'));
if (!response.ok) { if (!response.ok) {
throw new Error(`API error: ${response.status}`); throw new Error(`API error: ${response.status}`);
} }
@@ -148,11 +149,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));
@@ -214,7 +225,7 @@ export default function Home() {
{/* Last update indicator */} {/* Last update indicator */}
{lastUpdate && ( {lastUpdate && (
<div className="text-xs text-gray-500 text-center sm:text-left mb-4"> <div className="text-xs text-gray-500 text-center sm:text-left mb-4">
Last updated: {lastUpdate.toLocaleTimeString()} Aktualisiert: {lastUpdate.toLocaleTimeString()}
</div> </div>
)} )}
@@ -258,7 +269,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 +328,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 +352,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

16
src/lib/baseUrl.ts Normal file
View File

@@ -0,0 +1,16 @@
declare const process: { env: { NEXT_PUBLIC_BASE_URL?: string } };
export function withBaseUrl(path: string): string {
let base = '';
if (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_BASE_URL) {
base = process.env.NEXT_PUBLIC_BASE_URL;
}
if (!base || base === '/') return path;
// Ensure base starts with / and does not end with /
if (!base.startsWith('/')) base = '/' + base;
if (base.endsWith('/')) base = base.slice(0, -1);
// Ensure path starts with /
if (!path.startsWith('/')) path = '/' + path;
// Avoid double slashes
return base + path;
}

View File

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

15
tooling/ascii.py Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/python
from pyfiglet import Figlet
import sys
def asciiart(text: str) -> str:
f = Figlet(font="slant")
return f.renderText(text)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python ascii.py \"TEXT\"")
sys.exit(1)
text = sys.argv[1]
print(asciiart(text))