Compare commits
66 Commits
96ee4003d6
...
subpath
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c74bb21f | |||
| e3d8ba1017 | |||
| a9879d9fa4 | |||
| 7d387080f5 | |||
| 09a673b65b | |||
| 6665f65529 | |||
| 7629164387 | |||
| 21f13ef8ae | |||
| f94ddaa3b1 | |||
| 559abe3933 | |||
| 525e4fdc35 | |||
| 5a49f37750 | |||
| 2a7a0fadce | |||
| bb83c6db33 | |||
| a401732d7d | |||
| baad7309df | |||
| 4da88915f1 | |||
| 5e2c95b08d | |||
| 00fe8e7107 | |||
| 24ef59f0ed | |||
| b0033a5671 | |||
| 6b5705680a | |||
| b4b41ebcfd | |||
| d660c88c68 | |||
| 163ced296a | |||
| ce6037350c | |||
| e444d0d7ae | |||
| a843208422 | |||
| 8463edd262 | |||
| 477d326853 | |||
| 399509e2b5 | |||
| 2c933b90fd | |||
| 2a0a0c9f38 | |||
| 2ecdb9c002 | |||
| e4c6a7e0a8 | |||
| 784dcbf91c | |||
| 2d373da4c5 | |||
| 0878b7dcec | |||
| b0b6625810 | |||
|
|
fbc41654e0 | ||
|
|
5ad73485ce | ||
|
|
7e2ada529d | ||
| da5fbfa687 | |||
| 5e8c6f24f2 | |||
| d4bcbe95bb | |||
| 1a07ff0ffd | |||
| d51dc983ec | |||
| 95ccb2b916 | |||
| 5c53f370af | |||
| e78e73286c | |||
| 0c14a9c272 | |||
| ef1a607766 | |||
| 309b5a47df | |||
| 22042c7400 | |||
| 551d54c1b4 | |||
| 15afa15794 | |||
| 9d6eee0f93 | |||
| 69e6336d5c | |||
| 61b6f53b60 | |||
| 1cc864e4f0 | |||
| 7b556b2d09 | |||
| dd31bb735e | |||
| 82f4f5a07d | |||
| 1054820ce7 | |||
| e17b15ab28 | |||
| f4ef989d3a |
@@ -1,10 +1,15 @@
|
||||
#-------------------------------------------------------------------- # -----------------------------------------------------------------------#
|
||||
# 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_ABOUT_ME_LINK="http://localhost:80" # Your WebPage goes here #
|
||||
NEXT_SOCIAL_INSTAGRAM="http://instagram.com/rattatwinko" # Your Instagram Link goes here #
|
||||
NEXT_SOCIAL_TWITTER="https://twitter.com/user" # I dont have Twitter , if you have put your user there. #
|
||||
NEXT_SOCIAL_GITHUB_STATE="true" # I Have GitHub so this is True (if you dont then set this to false) #
|
||||
NEXT_SOCIAL_GITHUB_LINK_IF_TRUE="http://github.com/ZockerKatze" # If you have GitHub then paste your link here #
|
||||
PORT=8080 # This is unused. You can safely delete if you want. #
|
||||
NEXT_SOCIAL_BUYMEACOFFEE="https://coff.ee/rattatwinko"
|
||||
PORT=8080 # This is unused. You can safely delete if you want. #
|
||||
BASE_URL=/blog # This is the subpath!
|
||||
@@ -12,13 +12,50 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t markdownblog .
|
||||
run: docker build -t localhost:3002/rattatwinko/markdownblog:latest .
|
||||
|
||||
- name: Push Docker image
|
||||
run: docker push registry.gitea.com/user/markdownblog:latest
|
||||
- name: Save Docker image as tarball
|
||||
run: docker save localhost:3002/rattatwinko/markdownblog:latest -o markdownblog-image.tar
|
||||
|
||||
- name: Deploy to Gitea
|
||||
- name: Upload Docker image artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: markdownblog-docker-image
|
||||
path: markdownblog-image.tar
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -3,4 +3,17 @@ node_modules
|
||||
electron/dist
|
||||
posts/admin.json
|
||||
posts/admin.json.tmp
|
||||
.vscode
|
||||
.vscode
|
||||
posts/pinned.json
|
||||
posts/Aquaworld/tag-1.md
|
||||
posts/pinned.json
|
||||
posts/pinned.json
|
||||
|
||||
# Rust
|
||||
target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,3 +1,13 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:latest as rust-build
|
||||
WORKDIR /build
|
||||
COPY ./markdown_backend ./markdown_backend
|
||||
WORKDIR /build/markdown_backend
|
||||
RUN rustup target add x86_64-unknown-linux-musl
|
||||
RUN apt-get update && apt-get install -y musl-tools
|
||||
# Build with musl target for static linking
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
FROM node:20
|
||||
|
||||
WORKDIR /app
|
||||
@@ -7,9 +17,24 @@ COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
# Ensure posts directory exists and has correct permissions
|
||||
RUN mkdir -p /app/posts
|
||||
COPY posts/* /app/posts/
|
||||
RUN chmod -R 755 /app/posts
|
||||
|
||||
# Copy the statically linked Rust binary from the build stage
|
||||
COPY --from=rust-build /build/markdown_backend/target/x86_64-unknown-linux-musl/release/markdown_backend ./markdown_backend/target/release/markdown_backend
|
||||
RUN chmod +x ./markdown_backend/target/release/markdown_backend
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Create and set permissions for the docker volume mount point
|
||||
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"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
522
README.md
522
README.md
@@ -1,68 +1,223 @@
|
||||
# Markdown Blog
|
||||
# ✍🏼 Markdown Blog ✍🏻
|
||||
|
||||
A modern, cross-platform blog system built with **Next.js**, **Markdown**, and **Electron**. Write posts in Markdown, manage content visually, and deploy to web or desktop.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features
|
||||
## 🚀 Key Features
|
||||
|
||||
- Write and organize posts in Markdown
|
||||
- Visual admin dashboard
|
||||
- Responsive UI (Tailwind CSS)
|
||||
- Electron desktop app
|
||||
- Dockerized deployment with persistent storage
|
||||
- Secure admin password (bcrypt)
|
||||
- **📝 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
|
||||
- **📊 Rust Status Monitoring**: Real-time parser logs, performance metrics, and health monitoring
|
||||
- **📌 Pin Posts**: Pin important posts to the top of your blog
|
||||
- **📁 Folder Organization**: Organize posts in nested folders
|
||||
- **🖥️ Desktop App**: Electron-based desktop application
|
||||
- **🐳 Docker Support**: Containerized deployment with persistent storage
|
||||
- **🔒 Secure Admin**: Password-protected admin interface with bcrypt hashing
|
||||
- **📱 Responsive Design**: Mobile-friendly UI with Tailwind CSS
|
||||
- **🎯 Content Management**: Drag & drop file uploads, post editing, and deletion
|
||||
- **📦 Export Functionality**: Export all posts as tar.gz archive (Docker only)
|
||||
- **💾 Smart Caching**: RAM-based caching system for instant post retrieval
|
||||
- **🔧 VS Code-Style Editor**: Monaco editor with YAML frontmatter support and live preview
|
||||
- **🔄 Force Reparse**: Manual cache clearing and post reparsing for immediate updates
|
||||
- **📁 Reliable Directory Scanning**: Robust file system traversal with error handling
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technologies
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- Next.js 14
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Electron
|
||||
- Docker
|
||||
- **Frontend**: Next.js 14, React 18, TypeScript
|
||||
- **Backend**: Rust (markdown parsing, file watching, caching)
|
||||
- **Styling**: Tailwind CSS, @tailwindcss/typography
|
||||
- **Markdown**: pulldown-cmark, syntect (syntax highlighting), ammonia (HTML sanitization)
|
||||
- **Desktop**: Electron
|
||||
- **Security**: bcrypt, DOMPurify
|
||||
- **Deployment**: Docker, PM2
|
||||
- **Development**: ESLint, PostCSS, Autoprefixer
|
||||
- **Real-time**: Server-Sent Events (SSE)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Project Structure
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
markdownblog/
|
||||
├── posts/ # Markdown blog posts
|
||||
├── src/ # Next.js app code
|
||||
├── electron/ # Desktop app code
|
||||
├── public/ # Static assets
|
||||
├── Dockerfile # Docker configuration
|
||||
├── .dockerignore # Docker ignore rules
|
||||
└── manage_container.sh # Docker management script
|
||||
├── 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/
|
||||
│ ├── app/ # Next.js 14 App Router
|
||||
│ │ ├── admin/ # Admin dashboard pages
|
||||
│ │ │ ├── editor/ # VS Code-style editor
|
||||
│ │ │ │ └── page.tsx # Markdown editor with Monaco
|
||||
│ │ │ ├── manage/ # Content management interface
|
||||
│ │ │ │ ├── page.tsx # Manage posts and folders
|
||||
│ │ │ │ └── rust-status/ # Rust backend monitoring
|
||||
│ │ │ │ └── page.tsx # Parser logs and performance metrics
|
||||
│ │ │ └── page.tsx # Main admin dashboard
|
||||
│ │ ├── api/ # API routes (Next.js API routes)
|
||||
│ │ │ ├── admin/ # Admin API endpoints
|
||||
│ │ │ │ ├── delete/ # Delete posts/folders
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── docker/ # Docker detection
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── export/ # Export functionality (Docker)
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── exportlocal/ # Export functionality (Local)
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── folders/ # Folder management
|
||||
│ │ │ │ │ ├── details/ # Folder details API
|
||||
│ │ │ │ │ │ └── route.ts
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── password/ # Password management
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ ├── posts/ # Post CRUD operations
|
||||
│ │ │ │ │ ├── move/ # Move posts between folders
|
||||
│ │ │ │ │ │ └── route.ts
|
||||
│ │ │ │ │ ├── raw/ # Get raw post content
|
||||
│ │ │ │ │ │ └── route.ts
|
||||
│ │ │ │ │ ├── route.ts
|
||||
│ │ │ │ │ └── size/ # Get post size info
|
||||
│ │ │ │ │ └── route.ts
|
||||
│ │ │ │ └── upload/ # File upload handling
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ └── posts/ # Public post API
|
||||
│ │ │ ├── [slug]/ # Dynamic post API routes
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ ├── preview/ # Markdown preview API
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ ├── stream/ # Server-Sent Events for real-time updates
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ ├── webhook/ # Webhook endpoint
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ └── route.ts # List all posts
|
||||
│ │ ├── posts/ # Blog post pages
|
||||
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
|
||||
│ │ │ └── page.tsx # Individual post page with anchor linking and SSE
|
||||
│ │ ├── AboutButton.tsx # About page button component
|
||||
│ │ ├── BadgeButton.tsx # Badge display component
|
||||
│ │ ├── globals.css # Global styles and Tailwind imports
|
||||
│ │ ├── HeaderButtons.tsx # Header navigation buttons
|
||||
│ │ ├── highlight-github.css # Code syntax highlighting styles
|
||||
│ │ ├── layout.tsx # Root layout with metadata
|
||||
│ │ ├── MobileNav.tsx # Mobile navigation component
|
||||
│ │ ├── monaco-vim.d.ts # Monaco Vim typings
|
||||
│ │ └── page.tsx # Homepage with post listing
|
||||
│ └── lib/ # Utility libraries
|
||||
│ └── postsDirectory.ts # Post directory management and Rust integration
|
||||
├── posts/ # Markdown blog posts storage
|
||||
│ ├── about.md
|
||||
│ ├── welcome.md
|
||||
│ └── assets/
|
||||
│ └── peta.png
|
||||
├── public/ # Static assets
|
||||
│ ├── android-chrome-192x192.png
|
||||
│ ├── android-chrome-512x512.png
|
||||
│ ├── apple-touch-icon.png
|
||||
│ ├── favicon-16x16.png
|
||||
│ ├── favicon-32x32.png
|
||||
│ ├── favicon.ico
|
||||
│ └── site.webmanifest
|
||||
├── electron/ # Desktop application
|
||||
│ └── main.js # Electron main process configuration
|
||||
├── Dockerfile # Docker container configuration
|
||||
├── docker.sh # Docker deployment script
|
||||
├── entrypoint.sh # Container entrypoint script
|
||||
├── run-local-backend.sh # Local Rust backend runner
|
||||
├── next-env.d.ts # Next.js TypeScript definitions
|
||||
├── next.config.js # Next.js configuration
|
||||
├── package-lock.json # npm lock file
|
||||
├── package.json # Dependencies and scripts
|
||||
├── postcss.config.js # PostCSS configuration
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── LICENSE # MIT License
|
||||
```
|
||||
|
||||
### 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)
|
||||
- **`src/app/page.tsx`**: Homepage with responsive post listing and search
|
||||
- **`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/manage/page.tsx`**: Advanced content management interface
|
||||
- **`src/app/admin/rust-status/page.tsx`**: Rust backend monitoring and logs
|
||||
|
||||
#### API Routes
|
||||
- **Post Management**: CRUD operations for blog posts (Rust-powered)
|
||||
- **Folder Management**: Create, delete, and organize content structure
|
||||
- **Authentication**: Password management and validation
|
||||
- **Export**: Docker and local export functionality
|
||||
- **Upload**: Drag & drop file upload handling
|
||||
- **SSE Streaming**: Real-time updates via Server-Sent Events
|
||||
|
||||
#### Utilities
|
||||
- **`src/lib/postsDirectory.ts`**: File system operations and Rust backend integration
|
||||
|
||||
#### Desktop App
|
||||
- **`electron/main.js`**: Electron configuration for desktop application
|
||||
|
||||
#### Deployment
|
||||
- **`Dockerfile`**: Multi-stage build for production deployment
|
||||
- **`docker.sh`**: Automated deployment script with volume management
|
||||
- **`entrypoint.sh`**: Container initialization and post setup
|
||||
- **`run-local-backend.sh`**: Local Rust backend runner
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Getting Started
|
||||
## ⚡ Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- Docker (for containerized deployment)
|
||||
- **Node.js 18+**
|
||||
- **npm** or **yarn**
|
||||
- **Rust** (for local development)
|
||||
- **Docker** (for containerized deployment)
|
||||
|
||||
---
|
||||
### Local Development
|
||||
|
||||
## 🖥️ Local Development
|
||||
1. **Clone and install**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd markdownblog
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build Rust backend**:
|
||||
```bash
|
||||
cd markdown_backend
|
||||
cargo build --release
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Start development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Visit [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
4. **Desktop app development**:
|
||||
```bash
|
||||
npm run electron-dev
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd markdownblog
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
- Visit [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
# Build Rust backend
|
||||
cd markdown_backend && cargo build --release && cd ..
|
||||
|
||||
### Build for Production (Local)
|
||||
|
||||
```bash
|
||||
# Build Next.js frontend
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
@@ -71,53 +226,300 @@ npm start
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Build the Docker Image
|
||||
### Quick Deployment
|
||||
|
||||
Use the provided script for easy deployment:
|
||||
|
||||
```bash
|
||||
docker build -t markdownblog .
|
||||
chmod +x docker.sh
|
||||
./docker.sh
|
||||
```
|
||||
|
||||
### Run the Container with Persistent Storage
|
||||
This script will:
|
||||
- Build the Docker image (including Rust backend)
|
||||
- Create a persistent volume for posts
|
||||
- Run the container on port 8080
|
||||
- Copy built-in posts to the volume
|
||||
|
||||
```bash
|
||||
docker run -p 8080:3000 -v markdownblog-posts:/app/docker markdownblog
|
||||
```
|
||||
- The app will be available at [http://localhost:8080](http://localhost:8080)
|
||||
- All posts are stored persistently in the Docker volume `markdownblog-posts` (mapped to `/app/docker` in the container).
|
||||
### Manual Docker Commands
|
||||
|
||||
#### Using a Host Directory for Posts
|
||||
1. **Build the image**:
|
||||
```bash
|
||||
docker build -t markdownblog .
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -p 8080:3000 -v /absolute/path/to/posts:/app/docker markdownblog
|
||||
```
|
||||
2. **Run with persistent storage**:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name markdownblog \
|
||||
-p 8080:3000 \
|
||||
-v markdownblog-posts:/app/posts \
|
||||
markdownblog
|
||||
```
|
||||
|
||||
3. **Using host directory**:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name markdownblog \
|
||||
-p 8080:3000 \
|
||||
-v /path/to/your/posts:/app/posts \
|
||||
markdownblog
|
||||
```
|
||||
|
||||
### Docker Features
|
||||
|
||||
- **Persistent Storage**: Posts are stored in Docker volumes
|
||||
- **Export Functionality**: Export all posts as tar.gz (Docker only)
|
||||
- **Auto-restart**: Container automatically restarts on failure
|
||||
- **Built-in Posts**: Welcome and test posts included
|
||||
- **Rust Backend**: Pre-compiled Rust binaries for optimal performance
|
||||
|
||||
---
|
||||
|
||||
## 📝 Writing Posts
|
||||
## 📝 Writing Blog Posts
|
||||
|
||||
- Add Markdown files to the `posts/` directory (or the mounted volume).
|
||||
- Each post should have frontmatter:
|
||||
### Post Structure
|
||||
|
||||
Create Markdown files in the `posts/` directory with frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Your Post Title"
|
||||
date: "YYYY-MM-DD"
|
||||
tags: ["tag1", "tag2"]
|
||||
summary: "A brief summary of your post"
|
||||
date: "2024-01-15"
|
||||
tags: ["technology", "programming", "web"]
|
||||
summary: "A brief description of your post content"
|
||||
author: "Your Name"
|
||||
---
|
||||
|
||||
Your post content here...
|
||||
|
||||
## Headers
|
||||
|
||||
Regular Markdown syntax is supported with automatic anchor linking.
|
||||
|
||||
### Code Blocks
|
||||
|
||||
```javascript
|
||||
console.log("Hello, World!");
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Nested item
|
||||
|
||||
### Post Organization
|
||||
|
||||
- **Root Level**: Posts directly in `posts/` folder
|
||||
- **Folders**: Create subdirectories for organization
|
||||
- **Nested Structure**: Support for unlimited nesting levels
|
||||
- **Real-time Updates**: Changes are reflected immediately via SSE
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Admin Password Security
|
||||
## 🔐 Admin Dashboard
|
||||
|
||||
- Admin password is securely hashed with bcrypt and stored in `posts/admin.json`.
|
||||
- Never commit your `posts/admin.json` file.
|
||||
### Access
|
||||
|
||||
- **URL**: `/admin`
|
||||
- **Default Username**: `admin`
|
||||
- **Password**: Set via API or environment variable
|
||||
|
||||
### Features
|
||||
|
||||
- **📝 Create Posts**: Rich text editor with live Markdown preview
|
||||
- **📁 Manage Folders**: Create and organize content structure
|
||||
- **📌 Pin Posts**: Pin important posts to the top
|
||||
- **🔄 Edit Posts**: In-place editing with frontmatter support
|
||||
- **🗑️ Delete Content**: Remove posts and folders
|
||||
- **📤 Upload Files**: Drag & drop Markdown files
|
||||
- **🔐 Change Password**: Secure password management
|
||||
- **📦 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
|
||||
|
||||
- **Password Hashing**: bcrypt with salt
|
||||
- **Session Management**: Local storage-based authentication
|
||||
- **Input Sanitization**: Ammonia for XSS protection
|
||||
- **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
|
||||
|
||||
### Styling
|
||||
|
||||
- **Tailwind CSS**: Utility-first CSS framework
|
||||
- **Typography**: @tailwindcss/typography for content styling
|
||||
- **Syntax Highlighting**: syntect with GitHub theme
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Next.js Config**: `next.config.js`
|
||||
- **Tailwind Config**: `tailwind.config.js`
|
||||
- **TypeScript Config**: `tsconfig.json`
|
||||
- **PostCSS Config**: `postcss.config.js`
|
||||
- **Rust Config**: `markdown_backend/Cargo.toml`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server
|
||||
npm run build # Build for production
|
||||
npm run start # Start production server
|
||||
npm run lint # Run ESLint
|
||||
npm run electron # Start Electron app
|
||||
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
|
||||
|
||||
MIT
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly (both frontend and Rust backend)
|
||||
5. Submit a pull request
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Port conflicts**: Change port in `docker.sh` or Docker run command
|
||||
- **Permission errors**: Ensure `docker.sh` is executable (`chmod +x docker.sh`)
|
||||
- **Volume issues**: Check Docker volume exists and has proper permissions
|
||||
- **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
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
46
docker.sh
46
docker.sh
@@ -2,6 +2,12 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Check if Docker daemon is running
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Error: Docker daemon is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_NAME="markdownblog"
|
||||
CONTAINER_NAME="markdownblog"
|
||||
VOLUME_NAME="markdownblog-posts"
|
||||
@@ -32,5 +38,43 @@ docker run -d \
|
||||
echo "Copying built-in posts to Docker volume if empty..."
|
||||
docker exec $CONTAINER_NAME sh -c 'if [ -d /app/posts ] && [ -d /app/docker ] && [ "$(ls -A /app/docker)" = "" ]; then cp -r /app/posts/* /app/docker/; fi'
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps | grep -q $CONTAINER_NAME; then
|
||||
echo "Error: Container failed to start. Check logs with: docker logs $CONTAINER_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output with colors
|
||||
GREEN='\033[1;32m' # Green
|
||||
CYAN='\033[1;36m'
|
||||
RESET='\033[0m'
|
||||
|
||||
echo ""
|
||||
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
|
||||
|
||||
@@ -2,6 +2,13 @@ const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/*
|
||||
|
||||
This will be discontinued in a bit.
|
||||
Either move to Docker or get fucked.
|
||||
|
||||
*/
|
||||
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -14,7 +21,9 @@ function createWindow() {
|
||||
|
||||
// Load the Next.js app
|
||||
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();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../.next/server/pages/index.html'));
|
||||
|
||||
357
flowcharts/backend.drawio
Normal file
357
flowcharts/backend.drawio
Normal 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="<div>Cache</div>" 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="<div>Cache</div>" 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="&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="&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="<div>Frontend API</div>" 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>
|
||||
266
flowcharts/docker_build_flowchart.drawio
Normal file
266
flowcharts/docker_build_flowchart.drawio
Normal 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 & 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>
|
||||
21
markdown_backend/Cargo.toml
Normal file
21
markdown_backend/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "markdown_backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
pulldown-cmark = "0.9"
|
||||
gray_matter = "0.2.8"
|
||||
ammonia = "3.1"
|
||||
slug = "0.1"
|
||||
notify = "6.1"
|
||||
syntect = { version = "5.1", features = ["default"] }
|
||||
regex = "1.10"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
html-escape = "0.2.13"
|
||||
once_cell = "1.18"
|
||||
sysinfo = "0.30.7"
|
||||
187
markdown_backend/src/main.rs
Normal file
187
markdown_backend/src/main.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
#[warn(unused_imports)]
|
||||
use clap::{Parser, Subcommand};
|
||||
mod markdown;
|
||||
use markdown::{
|
||||
get_all_posts,
|
||||
get_post_by_slug,
|
||||
get_posts_by_tag,
|
||||
watch_posts,
|
||||
get_parser_logs,
|
||||
clear_parser_logs,
|
||||
load_parser_logs_from_disk,
|
||||
force_reinterpret_all_posts,
|
||||
force_reparse_single_post
|
||||
};
|
||||
use serde_json;
|
||||
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)]
|
||||
#[command(name = "Markdown Backend")]
|
||||
#[command(about = "Ein CLI für die Verwaltung von Markdown-Blogbeiträgen", long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// List all posts
|
||||
List,
|
||||
/// Show a post by slug
|
||||
Show {
|
||||
slug: String,
|
||||
},
|
||||
/// List posts by tag
|
||||
Tags {
|
||||
tag: String,
|
||||
},
|
||||
/// Watch for changes in the posts directory
|
||||
Watch,
|
||||
/// Show Rust parser statistics
|
||||
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() {
|
||||
markdown::load_post_cache_from_disk();
|
||||
load_parser_logs_from_disk();
|
||||
let cli = Cli::parse();
|
||||
match &cli.command {
|
||||
Commands::List => {
|
||||
let posts = get_all_posts().unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!("{}", serde_json::to_string(&posts).unwrap());
|
||||
}
|
||||
Commands::Show { slug } => {
|
||||
match get_post_by_slug(slug) {
|
||||
Ok(post) => {
|
||||
println!("{}", serde_json::to_string(&post).unwrap());
|
||||
markdown::save_post_cache_to_disk();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Tags { tag } => {
|
||||
let posts = get_posts_by_tag(tag).unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!("{}", serde_json::to_string(&posts).unwrap());
|
||||
}
|
||||
Commands::Watch => {
|
||||
println!("Überwache Änderungen im Posts-Verzeichnis. Drücken Sie Strg+C zum Beenden.");
|
||||
let _ = watch_posts(|| {
|
||||
println!("Posts-Verzeichnis hat sich geändert!");
|
||||
});
|
||||
// Keep the main thread alive
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
Commands::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
911
markdown_backend/src/markdown.rs
Normal file
911
markdown_backend/src/markdown.rs
Normal file
@@ -0,0 +1,911 @@
|
||||
//
|
||||
// src/markdown.rs
|
||||
// Written by: @rattatwinko
|
||||
//
|
||||
|
||||
use std::fs;
|
||||
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 serde::{Deserialize, Serialize};
|
||||
use pulldown_cmark::{Parser, Options, html, Event, Tag, CowStr};
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use slug::slugify;
|
||||
use notify::{RecursiveMode, RecommendedWatcher, Watcher, Config};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::html::highlighted_html_for_string;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json;
|
||||
use sysinfo::{System, RefreshKind, CpuRefreshKind, ProcessRefreshKind};
|
||||
use regex::Regex;
|
||||
|
||||
// Constants
|
||||
const POSTS_CACHE_PATH: &str = "./cache/posts_cache.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";
|
||||
|
||||
// Data structures
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct PostFrontmatter {
|
||||
pub title: String,
|
||||
pub date: String,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
// Post Data Structures
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub date: String,
|
||||
pub tags: Vec<String>,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
// Data Structure for Posts Statistics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PostStats {
|
||||
pub slug: String,
|
||||
pub cache_hits: u64,
|
||||
pub cache_misses: u64,
|
||||
pub last_interpret_time_ms: u128,
|
||||
pub last_compile_time_ms: u128,
|
||||
pub last_cpu_usage_percent: f32,
|
||||
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 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 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 {
|
||||
let is_docker = std::env::var("DOCKER_CONTAINER").is_ok()
|
||||
|| std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
|
||||
|| std::path::Path::new("/.dockerenv").exists();
|
||||
|
||||
let candidates = if is_docker {
|
||||
vec![
|
||||
"/app/docker", // Docker volume mount point (highest priority in Docker)
|
||||
"/app/posts", // Fallback in Docker
|
||||
"./posts",
|
||||
"../posts",
|
||||
"/posts",
|
||||
"/docker"
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"./posts",
|
||||
"../posts",
|
||||
"/posts",
|
||||
"/docker",
|
||||
"/app/docker" // Lower priority for non-Docker environments
|
||||
]
|
||||
};
|
||||
|
||||
for candidate in candidates.iter() {
|
||||
let path = PathBuf::from(candidate);
|
||||
if path.exists() && path.is_dir() {
|
||||
add_log("info", &format!("Verwende Posts-Verzeichnis: {:?}", path), None, None);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: create ./posts if it doesn't exist
|
||||
let fallback_path = PathBuf::from("./posts");
|
||||
if !fallback_path.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&fallback_path) {
|
||||
add_log("error", &format!("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>> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
match metadata.created() {
|
||||
Ok(created) => Ok(DateTime::<Utc>::from(created)),
|
||||
Err(_) => {
|
||||
let modified = metadata.modified()?;
|
||||
Ok(DateTime::<Utc>::from(modified))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Frontend expects a plain old string that will be used for the anchor
|
||||
// something like this -> #i-am-a-heading
|
||||
// This creates a crossreference for Links that scroll to said heading
|
||||
fn process_anchor_links(content: &str) -> String {
|
||||
let re = regex::Regex::new(r"\[([^\]]+)\]\(#([^)]+)\)").unwrap();
|
||||
re.replace_all(content, |caps: ®ex::Captures| {
|
||||
let link_text = &caps[1];
|
||||
let anchor = &caps[2];
|
||||
let slugified = slugify(anchor);
|
||||
format!("[{}](#{})", link_text, slugified)
|
||||
}).to_string()
|
||||
}
|
||||
|
||||
// Here we just remove the Emoji if it is in the heading.
|
||||
// Example "🏳️🌈 Hi!" will turn into "#hi"
|
||||
fn strip_emojis(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| {
|
||||
let c = *c as u32;
|
||||
!( (c >= 0x1F600 && c <= 0x1F64F) // Emoticons
|
||||
|| (c >= 0x1F300 && c <= 0x1F5FF) // Misc Symbols and Pictographs
|
||||
|| (c >= 0x1F680 && c <= 0x1F6FF) // Transport and Map
|
||||
|| (c >= 0x2600 && c <= 0x26FF) // Misc symbols
|
||||
|| (c >= 0x2700 && c <= 0x27BF) // Dingbats
|
||||
|| (c >= 0x1F900 && c <= 0x1F9FF) // Supplemental Symbols and Pictographs
|
||||
|| (c >= 0x1FA70 && c <= 0x1FAFF) // Symbols and Pictographs Extended-A
|
||||
|| (c >= 0x1F1E6 && c <= 0x1F1FF) // Regional Indicator Symbols
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// This is a obsolete Function for Custom Tags for HTML
|
||||
// Example usage in Text: <warning />
|
||||
fn process_custom_tags(content: &str) -> String {
|
||||
let mut processed = content.to_string();
|
||||
|
||||
// Handle simple tags without parameters
|
||||
let simple_tags = [
|
||||
("<mytag />", "<div class=\"custom-tag mytag\">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: ®ex::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 {
|
||||
let _ = get_all_posts();
|
||||
let stats = POST_STATS.read().unwrap();
|
||||
let values: Vec<&PostStats> = stats.values().collect();
|
||||
if values.is_empty() {
|
||||
"[]".to_string()
|
||||
} else {
|
||||
serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_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>> {
|
||||
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()));
|
||||
sys.refresh_processes();
|
||||
let pid = sysinfo::get_current_pid()?;
|
||||
let before_cpu = sys.process(pid).map(|p| p.cpu_usage()).unwrap_or(0.0);
|
||||
let start = Instant::now();
|
||||
|
||||
let mut stats = POST_STATS.write().unwrap();
|
||||
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
||||
slug: slug.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Try cache first
|
||||
if let Some(post) = POST_CACHE.read().unwrap().get(slug).cloned() {
|
||||
entry.cache_hits += 1;
|
||||
entry.last_interpret_time_ms = 0;
|
||||
entry.last_compile_time_ms = 0;
|
||||
entry.last_cache_status = "hit".to_string();
|
||||
sys.refresh_process(pid);
|
||||
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);
|
||||
}
|
||||
|
||||
entry.cache_misses += 1;
|
||||
entry.last_cache_status = "miss".to_string();
|
||||
drop(stats);
|
||||
|
||||
let posts_dir = get_posts_directory();
|
||||
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)?;
|
||||
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 result = matter.parse(&file_content);
|
||||
|
||||
let front: PostFrontmatter = if let Some(data) = result.data {
|
||||
match data.deserialize() {
|
||||
Ok(front) => front,
|
||||
Err(e) => {
|
||||
let error_msg = format!("Fehler beim Deserialisieren des Frontmatters: {}", e);
|
||||
add_log("error", &error_msg, Some(slug), None);
|
||||
return Err(error_msg.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
add_log("error", "Kein Frontmatter gefunden", Some(slug), None);
|
||||
return Err("Kein Frontmatter gefunden".into());
|
||||
};
|
||||
|
||||
let created_at = get_file_creation_date(&file_path)?;
|
||||
let processed_markdown = process_anchor_links(&result.content);
|
||||
let processed_markdown = process_custom_tags(&processed_markdown);
|
||||
|
||||
add_log("info", "Starte Markdown-Parsing", Some(slug), Some(&format!("Inhaltslänge: {} Zeichen", processed_markdown.len())));
|
||||
|
||||
let parser = Parser::new_ext(&processed_markdown, Options::all());
|
||||
let mut html_output = String::new();
|
||||
let mut heading_text = String::new();
|
||||
let mut in_heading = false;
|
||||
let mut heading_level = 0;
|
||||
let mut in_code_block = false;
|
||||
let mut code_block_lang = String::new();
|
||||
let mut code_block_content = String::new();
|
||||
let mut events = Vec::new();
|
||||
let ss = SyntaxSet::load_defaults_newlines();
|
||||
let ts = ThemeSet::load_defaults();
|
||||
let theme = &ts.themes["base16-ocean.dark"];
|
||||
|
||||
let start_parsing = Instant::now();
|
||||
let mut event_count = 0;
|
||||
|
||||
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 {
|
||||
Event::Start(Tag::Heading(level, _, _)) => {
|
||||
in_heading = true;
|
||||
heading_level = *level as usize;
|
||||
heading_text.clear();
|
||||
},
|
||||
Event::End(Tag::Heading(_, _, _)) => {
|
||||
in_heading = false;
|
||||
let heading_no_emoji = strip_emojis(&heading_text);
|
||||
let id = slugify(&heading_no_emoji);
|
||||
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::Html(CowStr::Boxed(format!("</h{lvl}>", lvl=heading_level).into_boxed_str())));
|
||||
},
|
||||
Event::Text(text) if in_heading => {
|
||||
heading_text.push_str(text);
|
||||
},
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
in_code_block = true;
|
||||
code_block_content.clear();
|
||||
code_block_lang = match kind {
|
||||
pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
|
||||
pulldown_cmark::CodeBlockKind::Indented => String::new(),
|
||||
};
|
||||
},
|
||||
Event::End(Tag::CodeBlock(_)) => {
|
||||
in_code_block = false;
|
||||
let highlighted = if !code_block_lang.is_empty() {
|
||||
if let Some(syntax) = ss.find_syntax_by_token(&code_block_lang) {
|
||||
highlighted_html_for_string(&code_block_content, &ss, syntax, theme).unwrap_or_else(|_| format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content)))
|
||||
} 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 {
|
||||
format!("<pre style=\"background: #2d2d2d; color: #f8f8f2; padding: 1em; border-radius: 6px; overflow-x: auto;\"><code style=\"background: none;\">{}</code></pre>", html_escape::encode_text(&code_block_content))
|
||||
};
|
||||
events.push(Event::Html(CowStr::Boxed(highlighted.into_boxed_str())));
|
||||
},
|
||||
Event::Text(text) if in_code_block => {
|
||||
code_block_content.push_str(text);
|
||||
},
|
||||
_ if !in_heading && !in_code_block => {
|
||||
events.push(event);
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
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 interpret_time = start.elapsed();
|
||||
let compile_start = Instant::now();
|
||||
let post = Post {
|
||||
slug: slug.to_string(),
|
||||
title: front.title,
|
||||
date: front.date,
|
||||
tags: front.tags.unwrap_or_default(),
|
||||
summary: front.summary,
|
||||
content: sanitized_html,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
author: std::env::var("BLOG_OWNER").unwrap_or_else(|_| "Anonym".to_string()),
|
||||
};
|
||||
let compile_time = compile_start.elapsed();
|
||||
|
||||
// Insert into cache
|
||||
// If this no worky , programm fucky wucky? - Check Logs
|
||||
POST_CACHE.write().unwrap().insert(slug.to_string(), post.clone());
|
||||
|
||||
// Update stats
|
||||
let mut stats = POST_STATS.write().unwrap();
|
||||
let entry = stats.entry(slug.to_string()).or_insert_with(|| PostStats {
|
||||
slug: slug.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
entry.last_interpret_time_ms = interpret_time.as_millis();
|
||||
entry.last_compile_time_ms = compile_time.as_millis();
|
||||
sys.refresh_process(pid);
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn get_all_posts() -> Result<Vec<Post>, Box<dyn std::error::Error>> {
|
||||
// Try cache first
|
||||
if let Some(posts) = ALL_POSTS_CACHE.read().unwrap().clone() {
|
||||
return Ok(posts);
|
||||
}
|
||||
|
||||
let posts_dir = get_posts_directory();
|
||||
let markdown_files = find_markdown_files(&posts_dir)?;
|
||||
let mut posts = Vec::new();
|
||||
|
||||
for file_path in markdown_files {
|
||||
let slug = path_to_slug(&file_path, &posts_dir);
|
||||
if let Ok(post) = get_post_by_slug(&slug) {
|
||||
POST_CACHE.write().unwrap().insert(slug.clone(), post.clone());
|
||||
posts.push(post);
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
*ALL_POSTS_CACHE.write().unwrap() = Some(posts.clone());
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
pub fn get_posts_by_tag(tag: &str) -> Result<Vec<Post>, Box<dyn std::error::Error>> {
|
||||
let all_posts = get_all_posts()?;
|
||||
Ok(all_posts.into_iter().filter(|p| p.tags.contains(&tag.to_string())).collect())
|
||||
}
|
||||
|
||||
pub fn watch_posts<F: Fn() + Send + 'static>(on_change: F) -> notify::Result<RecommendedWatcher> {
|
||||
let (tx, rx) = channel();
|
||||
let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
|
||||
watcher.watch(get_posts_directory().as_path(), RecursiveMode::Recursive)?;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(_event) => {
|
||||
POST_CACHE.write().unwrap().clear();
|
||||
*ALL_POSTS_CACHE.write().unwrap() = None;
|
||||
on_change();
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Überwachungsfehler: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
pub fn load_post_cache_from_disk() {
|
||||
if let Ok(data) = fs::read_to_string(POSTS_CACHE_PATH) {
|
||||
if let Ok(map) = serde_json::from_str::<HashMap<String, Post>>(&data) {
|
||||
*POST_CACHE.write().unwrap() = map;
|
||||
}
|
||||
}
|
||||
if let Ok(data) = fs::read_to_string(POST_STATS_PATH) {
|
||||
if let Ok(map) = serde_json::from_str::<HashMap<String, PostStats>>(&data) {
|
||||
*POST_STATS.write().unwrap() = map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_post_cache_to_disk() {
|
||||
ensure_cache_directory();
|
||||
if let Ok(map) = serde_json::to_string(&*POST_CACHE.read().unwrap()) {
|
||||
let _ = fs::write(POSTS_CACHE_PATH, map);
|
||||
}
|
||||
if let Ok(map) = serde_json::to_string(&*POST_STATS.read().unwrap()) {
|
||||
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)
|
||||
}
|
||||
@@ -3,6 +3,28 @@ const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
// Exclude SSE route from static generation
|
||||
experimental: {
|
||||
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
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/posts/stream',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
251
package-lock.json
generated
251
package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "markdownblog",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^18.2.61",
|
||||
@@ -15,18 +17,24 @@
|
||||
"autoprefixer": "^10.4.17",
|
||||
"bcrypt": "^5.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.0.9",
|
||||
"electron": "^29.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"marked": "^12.0.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-vim": "^0.4.2",
|
||||
"next": "14.1.0",
|
||||
"pm2": "^6.0.8",
|
||||
"postcss": "^8.4.35",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
@@ -301,6 +309,15 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.6.tgz",
|
||||
"integrity": "sha512-nz//dBr99hXZmHp10wgNI00qThWImkzRR5PQjvRM+rpmuHO5rYBJCqPPWufidCvmkkryXx/GOP/lgqsM3R3Org==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -501,6 +518,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
@@ -533,6 +556,29 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
|
||||
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
|
||||
@@ -1167,19 +1213,6 @@
|
||||
"parse5": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsdom/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
@@ -2213,6 +2246,18 @@
|
||||
"integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==",
|
||||
"license": "MIT/X11"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -2827,6 +2872,21 @@
|
||||
"integrity": "sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
||||
"integrity": "sha512-6PDYZGlhidt+Kc0ay890IU4HLNfIR7/OxPvcNxw+nJ4HQhMKd8pnGnPn4n2vqC/arRFCNWQhgJP8rpsYKsz0GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -3799,6 +3859,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
@@ -5105,6 +5171,92 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic-dompurify": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz",
|
||||
"integrity": "sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"jsdom": "^26.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-dompurify/node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-dompurify/node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-dompurify/node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -5249,18 +5401,6 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/rrweb-cssom": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
@@ -5622,6 +5762,21 @@
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-vim": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.2.tgz",
|
||||
"integrity": "sha512-rdbQC3O2rmpwX2Orzig/6gZjZfH7q7TIeB+uEl49sa+QyNm3jCKJOw5mwxBdFzTqbrPD+URfg6A2lEkuL5kymw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"monaco-editor": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -6221,6 +6376,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -6783,6 +6950,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-chartjs-2": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
|
||||
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -7538,6 +7715,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -8137,6 +8320,24 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"electron-dev": "concurrently \"npm run dev\" \"npm run electron\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^18.2.61",
|
||||
@@ -18,18 +20,24 @@
|
||||
"autoprefixer": "^10.4.17",
|
||||
"bcrypt": "^5.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.0.9",
|
||||
"electron": "^29.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"marked": "^12.0.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-vim": "^0.4.2",
|
||||
"next": "14.1.0",
|
||||
"pm2": "^6.0.8",
|
||||
"postcss": "^8.4.35",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
9
posts/about.md
Normal file
9
posts/about.md
Normal 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**_
|
||||
319
posts/mdtest.md
319
posts/mdtest.md
@@ -1,319 +0,0 @@
|
||||
---
|
||||
title: Markdown Demo!
|
||||
date: '2025-06-19'
|
||||
tags:
|
||||
- demo
|
||||
summary: Demo of Markdown Parsing
|
||||
author: Rattatwinko's
|
||||
---
|
||||
|
||||
# Markdown: Syntax
|
||||
|
||||
* [Overview](#overview)
|
||||
* [Philosophy](#philosophy)
|
||||
* [Inline HTML](#html)
|
||||
* [Automatic Escaping for Special Characters](#autoescape)
|
||||
* [Block Elements](#block)
|
||||
* [Paragraphs and Line Breaks](#p)
|
||||
* [Headers](#header)
|
||||
* [Blockquotes](#blockquote)
|
||||
* [Lists](#list)
|
||||
* [Code Blocks](#precode)
|
||||
* [Horizontal Rules](#hr)
|
||||
* [Span Elements](#span)
|
||||
* [Links](#link)
|
||||
* [Emphasis](#em)
|
||||
* [Code](#code)
|
||||
* [Images](#img)
|
||||
* [Miscellaneous](#misc)
|
||||
* [Backslash Escapes](#backslash)
|
||||
* [Automatic Links](#autolink)
|
||||
|
||||
|
||||
**Note:** This document is itself written using Markdown; you
|
||||
can [see the source for it by adding '.text' to the URL](/projects/markdown/syntax.text).
|
||||
|
||||
----
|
||||
|
||||
## Overview
|
||||
|
||||
### Philosophy
|
||||
|
||||
Markdown is intended to be as easy-to-read and easy-to-write as is feasible.
|
||||
|
||||
Readability, however, is emphasized above all else. A Markdown-formatted
|
||||
document should be publishable as-is, as plain text, without looking
|
||||
like it's been marked up with tags or formatting instructions. While
|
||||
Markdown's syntax has been influenced by several existing text-to-HTML
|
||||
filters -- including [Setext](http://docutils.sourceforge.net/mirror/setext.html), [atx](http://www.aaronsw.com/2002/atx/), [Textile](http://textism.com/tools/textile/), [reStructuredText](http://docutils.sourceforge.net/rst.html),
|
||||
[Grutatext](http://www.triptico.com/software/grutatxt.html), and [EtText](http://ettext.taint.org/doc/) -- the single biggest source of
|
||||
inspiration for Markdown's syntax is the format of plain text email.
|
||||
|
||||
## Block Elements
|
||||
|
||||
### Paragraphs and Line Breaks
|
||||
|
||||
A paragraph is simply one or more consecutive lines of text, separated
|
||||
by one or more blank lines. (A blank line is any line that looks like a
|
||||
blank line -- a line containing nothing but spaces or tabs is considered
|
||||
blank.) Normal paragraphs should not be indented with spaces or tabs.
|
||||
|
||||
The implication of the "one or more consecutive lines of text" rule is
|
||||
that Markdown supports "hard-wrapped" text paragraphs. This differs
|
||||
significantly from most other text-to-HTML formatters (including Movable
|
||||
Type's "Convert Line Breaks" option) which translate every line break
|
||||
character in a paragraph into a `<br />` tag.
|
||||
|
||||
When you *do* want to insert a `<br />` break tag using Markdown, you
|
||||
end a line with two or more spaces, then type return.
|
||||
|
||||
### Headers
|
||||
|
||||
Markdown supports two styles of headers, [Setext] [1] and [atx] [2].
|
||||
|
||||
Optionally, you may "close" atx-style headers. This is purely
|
||||
cosmetic -- you can use this if you think it looks better. The
|
||||
closing hashes don't even need to match the number of hashes
|
||||
used to open the header. (The number of opening hashes
|
||||
determines the header level.)
|
||||
|
||||
|
||||
### Blockquotes
|
||||
|
||||
Markdown uses email-style `>` characters for blockquoting. If you're
|
||||
familiar with quoting passages of text in an email message, then you
|
||||
know how to create a blockquote in Markdown. It looks best if you hard
|
||||
wrap the text and put a `>` before every line:
|
||||
|
||||
> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
|
||||
> consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
|
||||
> Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
|
||||
>
|
||||
> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
|
||||
> id sem consectetuer libero luctus adipiscing.
|
||||
|
||||
Markdown allows you to be lazy and only put the `>` before the first
|
||||
line of a hard-wrapped paragraph:
|
||||
|
||||
> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
|
||||
consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
|
||||
Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
|
||||
|
||||
> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
|
||||
id sem consectetuer libero luctus adipiscing.
|
||||
|
||||
Blockquotes can be nested (i.e. a blockquote-in-a-blockquote) by
|
||||
adding additional levels of `>`:
|
||||
|
||||
> This is the first level of quoting.
|
||||
>
|
||||
> > This is nested blockquote.
|
||||
>
|
||||
> Back to the first level.
|
||||
|
||||
Blockquotes can contain other Markdown elements, including headers, lists,
|
||||
and code blocks:
|
||||
|
||||
> ## This is a header.
|
||||
>
|
||||
> 1. This is the first list item.
|
||||
> 2. This is the second list item.
|
||||
>
|
||||
> Here's some example code:
|
||||
>
|
||||
> return shell_exec("echo $input | $markdown_script");
|
||||
|
||||
Any decent text editor should make email-style quoting easy. For
|
||||
example, with BBEdit, you can make a selection and choose Increase
|
||||
Quote Level from the Text menu.
|
||||
|
||||
|
||||
### Lists
|
||||
|
||||
Markdown supports ordered (numbered) and unordered (bulleted) lists.
|
||||
|
||||
Unordered lists use asterisks, pluses, and hyphens -- interchangably
|
||||
-- as list markers:
|
||||
|
||||
* Red
|
||||
* Green
|
||||
* Blue
|
||||
|
||||
is equivalent to:
|
||||
|
||||
+ Red
|
||||
+ Green
|
||||
+ Blue
|
||||
|
||||
and:
|
||||
|
||||
- Red
|
||||
- Green
|
||||
- Blue
|
||||
|
||||
Ordered lists use numbers followed by periods:
|
||||
|
||||
1. Bird
|
||||
2. McHale
|
||||
3. Parish
|
||||
|
||||
It's important to note that the actual numbers you use to mark the
|
||||
list have no effect on the HTML output Markdown produces. The HTML
|
||||
Markdown produces from the above list is:
|
||||
|
||||
If you instead wrote the list in Markdown like this:
|
||||
|
||||
1. Bird
|
||||
1. McHale
|
||||
1. Parish
|
||||
|
||||
or even:
|
||||
|
||||
3. Bird
|
||||
1. McHale
|
||||
8. Parish
|
||||
|
||||
you'd get the exact same HTML output. The point is, if you want to,
|
||||
you can use ordinal numbers in your ordered Markdown lists, so that
|
||||
the numbers in your source match the numbers in your published HTML.
|
||||
But if you want to be lazy, you don't have to.
|
||||
|
||||
To make lists look nice, you can wrap items with hanging indents:
|
||||
|
||||
* Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
|
||||
Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi,
|
||||
viverra nec, fringilla in, laoreet vitae, risus.
|
||||
* Donec sit amet nisl. Aliquam semper ipsum sit amet velit.
|
||||
Suspendisse id sem consectetuer libero luctus adipiscing.
|
||||
|
||||
But if you want to be lazy, you don't have to:
|
||||
|
||||
* Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
|
||||
Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi,
|
||||
viverra nec, fringilla in, laoreet vitae, risus.
|
||||
* Donec sit amet nisl. Aliquam semper ipsum sit amet velit.
|
||||
Suspendisse id sem consectetuer libero luctus adipiscing.
|
||||
|
||||
List items may consist of multiple paragraphs. Each subsequent
|
||||
paragraph in a list item must be indented by either 4 spaces
|
||||
or one tab:
|
||||
|
||||
1. This is a list item with two paragraphs. Lorem ipsum dolor
|
||||
sit amet, consectetuer adipiscing elit. Aliquam hendrerit
|
||||
mi posuere lectus.
|
||||
|
||||
Vestibulum enim wisi, viverra nec, fringilla in, laoreet
|
||||
vitae, risus. Donec sit amet nisl. Aliquam semper ipsum
|
||||
sit amet velit.
|
||||
|
||||
2. Suspendisse id sem consectetuer libero luctus adipiscing.
|
||||
|
||||
It looks nice if you indent every line of the subsequent
|
||||
paragraphs, but here again, Markdown will allow you to be
|
||||
lazy:
|
||||
|
||||
* This is a list item with two paragraphs.
|
||||
|
||||
This is the second paragraph in the list item. You're
|
||||
only required to indent the first line. Lorem ipsum dolor
|
||||
sit amet, consectetuer adipiscing elit.
|
||||
|
||||
* Another item in the same list.
|
||||
|
||||
To put a blockquote within a list item, the blockquote's `>`
|
||||
delimiters need to be indented:
|
||||
|
||||
* A list item with a blockquote:
|
||||
|
||||
> This is a blockquote
|
||||
> inside a list item.
|
||||
|
||||
To put a code block within a list item, the code block needs
|
||||
to be indented *twice* -- 8 spaces or two tabs:
|
||||
|
||||
* A list item with a code block:
|
||||
|
||||
<code goes here>
|
||||
|
||||
### Code Blocks
|
||||
|
||||
Pre-formatted code blocks are used for writing about programming or
|
||||
markup source code. Rather than forming normal paragraphs, the lines
|
||||
of a code block are interpreted literally. Markdown wraps a code block
|
||||
in both `<pre>` and `<code>` tags.
|
||||
|
||||
To produce a code block in Markdown, simply indent every line of the
|
||||
block by at least 4 spaces or 1 tab.
|
||||
|
||||
This is a normal paragraph:
|
||||
|
||||
This is a code block.
|
||||
|
||||
Here is an example of AppleScript:
|
||||
|
||||
tell application "Foo"
|
||||
beep
|
||||
end tell
|
||||
|
||||
A code block continues until it reaches a line that is not indented
|
||||
(or the end of the article).
|
||||
|
||||
Within a code block, ampersands (`&`) and angle brackets (`<` and `>`)
|
||||
are automatically converted into HTML entities. This makes it very
|
||||
easy to include example HTML source code using Markdown -- just paste
|
||||
it and indent it, and Markdown will handle the hassle of encoding the
|
||||
ampersands and angle brackets. For example, this:
|
||||
|
||||
<div class="footer">
|
||||
© 2004 Foo Corporation
|
||||
</div>
|
||||
|
||||
Regular Markdown syntax is not processed within code blocks. E.g.,
|
||||
asterisks are just literal asterisks within a code block. This means
|
||||
it's also easy to use Markdown to write about Markdown's own syntax.
|
||||
|
||||
```
|
||||
tell application "Foo"
|
||||
beep
|
||||
end tell
|
||||
```
|
||||
|
||||
## Span Elements
|
||||
|
||||
### Links
|
||||
|
||||
Markdown supports two style of links: *inline* and *reference*.
|
||||
|
||||
In both styles, the link text is delimited by [square brackets].
|
||||
|
||||
To create an inline link, use a set of regular parentheses immediately
|
||||
after the link text's closing square bracket. Inside the parentheses,
|
||||
put the URL where you want the link to point, along with an *optional*
|
||||
title for the link, surrounded in quotes. For example:
|
||||
|
||||
This is [an example](http://example.com/) inline link.
|
||||
|
||||
[This link](http://example.net/) has no title attribute.
|
||||
|
||||
### Emphasis
|
||||
|
||||
Markdown treats asterisks (`*`) and underscores (`_`) as indicators of
|
||||
emphasis. Text wrapped with one `*` or `_` will be wrapped with an
|
||||
HTML `<em>` tag; double `*`'s or `_`'s will be wrapped with an HTML
|
||||
`<strong>` tag. E.g., this input:
|
||||
|
||||
*single asterisks*
|
||||
|
||||
_single underscores_
|
||||
|
||||
**double asterisks**
|
||||
|
||||
__double underscores__
|
||||
|
||||
### Code
|
||||
|
||||
To indicate a span of code, wrap it with backtick quotes (`` ` ``).
|
||||
Unlike a pre-formatted code block, a code span indicates code within a
|
||||
normal paragraph. For example:
|
||||
|
||||
Use the `printf()` function.
|
||||
@@ -1,3 +1,7 @@
|
||||
[
|
||||
"welcome"
|
||||
]
|
||||
{
|
||||
"pinned": [
|
||||
"welcome"
|
||||
],
|
||||
"folderEmojis": {
|
||||
}
|
||||
}
|
||||
570
posts/welcome.md
570
posts/welcome.md
@@ -1,127 +1,543 @@
|
||||
---
|
||||
title: Read Me . Markdown!
|
||||
title: Welcome to MarkdownBlog
|
||||
date: '2025-06-19'
|
||||
tags:
|
||||
- welcome
|
||||
- introduction
|
||||
summary: Read Me Please
|
||||
author: Rattatwinko's
|
||||
- getting-started
|
||||
- documentation
|
||||
summary: A comprehensive guide to getting started with MarkdownBlog
|
||||
author: Rattatwinko
|
||||
---
|
||||
|
||||
# Welcome to the Blog
|
||||
# Welcome to MarkdownBlog! 🚀
|
||||
|
||||
This blog was built as a response to the lack of blogging systems that accept "human readable" formats editable in a terminal emulator.
|
||||
> **Note for Server Admins**: You can safely delete this post after reading, but we recommend keeping it as a reference for future users.
|
||||
|
||||
**Prerequisites:**
|
||||
- NPM
|
||||
- Docker (optional)
|
||||
## 📋 Table of Contents
|
||||
|
||||
That's about it.
|
||||
- [Overview 🌐](#overview)
|
||||
- [Quick Start ⚡](#quick-start)
|
||||
- [Docker Deployment 🐳](#docker-deployment)
|
||||
- [Local Deployment 🖥️](#local-deployment)
|
||||
- [Technical Stack 🧠](#technical-stack)
|
||||
- [Features 🎉](#features)
|
||||
- [Administration 🚧](#administration)
|
||||
- [Customization 🎨](#customization)
|
||||
- [Creating Posts with MdB ✍](#creating-posts-with-mdb)
|
||||
- [Troubleshooting 🚨](#troubleshooting)
|
||||
- [Support 🤝](#support)
|
||||
- [Support the Project ❤️](#support-the-project)
|
||||
- [Acknowledgments 🙏](#acknowledgments)
|
||||
- [Folder Emoji Technical Note 📁](#folder-emoji-technical-note)
|
||||
- [API 🏗️](#api)
|
||||
- [Project Status & Todo 📊](#project-status--todo)
|
||||
- [Recent Changes 🚀](#recent-changes)
|
||||
|
||||
---
|
||||
|
||||
## Formatting
|
||||
## Overview
|
||||
|
||||
Standard Markdown is supported. HTML with some basic CSS too! External CSS files aren't supported. JavaScript files aren't supported either (and won't be in the future, as it's a safety risk).
|
||||
MarkdownBlog is a modern, lightweight blog platform built with Next.js that allows you to write content in Markdown and HTML. It's designed to be simple yet powerful, perfect for developers, writers, and anyone who wants a clean blogging experience.
|
||||
|
||||
**This is _Markdown_!**
|
||||
### Key Benefits
|
||||
- ⚡ **Fast & Lightweight**: Built with Next.js for optimal performance
|
||||
- 📝 **Markdown Support**: Write content in familiar Markdown syntax
|
||||
- 🎨 **Customizable**: Easy to customize with Tailwind CSS
|
||||
- 🔒 **Admin Panel**: Built-in administration interface
|
||||
- 🐳 **Docker Ready**: Containerized deployment support
|
||||
- 📱 **Responsive**: Works perfectly on all devices
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| <span style="color:blue;">data</span> | Path to data files to supply the data that will be passed into templates. |
|
||||
| engine | Engine to be used for processing templates. Handlebars is the default. |
|
||||
| ext | Extension to be used for destination files. |
|
||||
---
|
||||
|
||||
<p style="background-color:grey;color:white;font-style:italic;">If you noticed <span style="color: #5BCEFA; font-weight: bold;">data</span> was blue! This works due to the HTML / CSS / Markdown <span style="font-weight: bold; font-size: 1em; padding: 0.2em 0.4em; border-radius: 4px;"><span style="color: #5BCEFA;">T</span><span style="color: #F5A9B8;">r</span><span style="color: #FFFFFF;">a</span><span style="color: #F5A9B8;">n</span><span style="color: #5BCEFA;">s</span>piler</span>. This basically means you can embed HTML into your Markdown blog-styled posts, and it will interpret correctly!</p>
|
||||
## Quick Start
|
||||
|
||||
**HTML Example:**
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Git
|
||||
|
||||
<p style="text-align:center;font-style:italic;font-weight:bold;font-size:2em;background-color:grey;font-family:cursive;color:white;">Hello from HTML</p>
|
||||
### Installation Steps
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd markdownblog
|
||||
|
||||
Heres the fancy **source code** for the **interrested**:
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your blog will be available at `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
> **Note:** Docker deployment works very well now, and this is the suggested way of running MarkdownBlog. Folder emojis work perfectly within Docker!
|
||||
>
|
||||
> **Technical details:**
|
||||
> - Docker ensures a consistent environment, eliminating issues with local Node.js, npm, or OS differences.
|
||||
> - All dependencies, file paths, and permissions are managed inside the container, reducing "works on my machine" bugs.
|
||||
> - The Docker volume for `/app/posts` guarantees that your content and emoji settings persist across container restarts and upgrades.
|
||||
> - The API now reads the emoji and pin data fresh on every request, so changes are instantly reflected in the UI.
|
||||
|
||||
### Option 1: Quick Deployment Script
|
||||
|
||||
For a quick and easy deployment, use the included Docker script:
|
||||
|
||||
```bash
|
||||
# Make the script executable
|
||||
chmod +x docker.sh
|
||||
|
||||
# Run the deployment
|
||||
./docker.sh
|
||||
```
|
||||
|
||||
**Customize the port** by editing the script:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_NAME="markdownblog"
|
||||
CONTAINER_NAME="markdownblog"
|
||||
VOLUME_NAME="markdownblog-posts"
|
||||
PORT="8080" # ← Change this to your preferred port
|
||||
```
|
||||
|
||||
### Option 2: Manual Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build the Docker image
|
||||
docker build -t markdownblog .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name markdownblog \
|
||||
-p 8080:3000 \
|
||||
-v markdownblog-posts:/app/posts \
|
||||
markdownblog
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
markdownblog:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:3000"
|
||||
volumes:
|
||||
- markdownblog-posts:/app/posts
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
markdownblog-posts:
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Deployment
|
||||
|
||||
> **Warning:** Local deployment is now more buggy than the Docker deployment. For the best experience, use Docker.
|
||||
>
|
||||
> **Technical blah:**
|
||||
> - Local runs can be affected by mismatched Node.js versions, missing dependencies, or file permission issues.
|
||||
> - File system events and hot reloads may not always pick up changes to emoji or pin data, leading to stale UI or missing features.
|
||||
> - If you must use local, always restart the server after changing `pinned.json` or folder emoji settings.
|
||||
|
||||
If Docker isn't your thing, here's how to deploy locally:
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production Mode
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Custom Port
|
||||
```bash
|
||||
npm start -- --port 8080
|
||||
# or
|
||||
PORT=8080 npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack
|
||||
|
||||
MarkdownBlog is built with modern web technologies:
|
||||
|
||||
| Technology | Purpose | Version |
|
||||
|------------|---------|---------|
|
||||
| **Next.js** | React Framework | 14+ |
|
||||
| **TypeScript** | Type Safety | 5+ |
|
||||
| **Tailwind CSS** | Styling | 3+ |
|
||||
| **Marked.js** | Markdown Parsing | 9+ |
|
||||
| **Highlight.js** | Code Syntax Highlighting | 11+ |
|
||||
| **Node.js** | Runtime | 18+ |
|
||||
|
||||
### Additional Features
|
||||
- 🔧 **Hot Reload**: Instant updates during development
|
||||
- 📦 **Optimized Builds**: Automatic code splitting and optimization
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Markdown Support
|
||||
Write content using standard Markdown syntax:
|
||||
|
||||
```markdown
|
||||
# Headers
|
||||
## Subheaders
|
||||
|
||||
**Bold text** and *italic text*
|
||||
|
||||
- Bullet points
|
||||
- More items
|
||||
|
||||
1. Numbered lists
|
||||
2. Second item
|
||||
|
||||
[Links](https://example.com)
|
||||
|
||||

|
||||
```
|
||||
|
||||
### HTML Support
|
||||
For advanced styling, you can use HTML:
|
||||
|
||||
```html
|
||||
<p style="text-align:center;font-style:italic;font-weight:bold;font-size:2em;background-color:grey;font-family:cursive;color:white;">Hello from HTML</p>
|
||||
<span style="font-family: cursive; font-weight: bold; color: #ff6b6b;">
|
||||
Custom styled text
|
||||
</span>
|
||||
|
||||
<div style="background: linear-gradient(45deg, #667eea, #764ba2); padding: 20px; border-radius: 10px;">
|
||||
<h2 style="color: white;">Gradient Background</h2>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Some Technical Information
|
||||
|
||||
For development, I used:
|
||||
- Docker
|
||||
- TypeScript
|
||||
- Next.js
|
||||
- Git (of course)
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
If you are deploying this on your machine for whatever reason, please note:
|
||||
- Docker building is fully implemented and supported.
|
||||
- Docker will deploy this app to port 8080 (http://localhost:8080)
|
||||
- If you run this with:
|
||||
|
||||
```sh
|
||||
npm install && npm run dev # or production (if you're fancy enough to deal with it)
|
||||
### Code Highlighting
|
||||
```javascript
|
||||
// JavaScript code with syntax highlighting
|
||||
function greet(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
```
|
||||
|
||||
- Note that the build times will take a while. Docker takes ages too, but once built it can recover stuff from the cache, so it's much faster after the first build.
|
||||
|
||||
---
|
||||
|
||||
## Administration
|
||||
|
||||
<p style="color:red;font-style:bolder;">Please set your name (or not) in @.env.local! Before deploying this to Docker!</p>
|
||||
### Default Credentials
|
||||
⚠️ **IMPORTANT**: Change these immediately after first login!
|
||||
|
||||
If you are an admin, then the default username and password are:
|
||||
- **Username**: `admin`
|
||||
- **Password**: `admin`
|
||||
|
||||
```
|
||||
user: admin
|
||||
password: admin
|
||||
### Accessing Admin Panel
|
||||
1. Navigate to `/admin` on your blog
|
||||
2. Enter your credentials
|
||||
3. Start managing your content!
|
||||
|
||||
### Admin Features
|
||||
- 📝 **Post Management**: Create, edit, delete posts
|
||||
- 📁 **File Upload**: Upload images and documents
|
||||
- 🔄 **Import/Export**: Backup and restore your content
|
||||
- ⚙️ **Settings**: Configure blog preferences
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Environment Variables
|
||||
Create a `.env.local` file , or use mine as a template:
|
||||
|
||||
```env
|
||||
|
||||
#NEXT_PUBLIC_BLOG_OWNER=Rattatwinko # Your Name 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_TWITTER="https://twitter.com/user" # I dont have Twitter , if you have put your user there.
|
||||
|
||||
#NEXT_SOCIAL_GITHUB_STATE="true" # I Have GitHub so this is True (if you dont then set this to false)
|
||||
|
||||
#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"
|
||||
|
||||
#PORT=8080
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> Change the administration password once the server is set up. This is really easy!
|
||||
> The server will store your password as a hash. So be careful of people getting that hash.
|
||||
### Styling Customization
|
||||
- Edit `src/app/globals.css` for global styles
|
||||
- Modify `tailwind.config.js` for theme customization
|
||||
- Update `src/app/layout.tsx` for layout changes
|
||||
|
||||
### Adding New Features
|
||||
The codebase is well-structured and documented. Key files:
|
||||
- `src/lib/markdown.ts` - Markdown processing
|
||||
- `src/lib/postsDirectory.ts` - Post management
|
||||
- `src/app/api/` - API routes
|
||||
- `src/app/admin/` - Admin interface
|
||||
|
||||
---
|
||||
|
||||
You can pin a post both in the UI and in the backend of the server.
|
||||
## Creating Posts with MdB
|
||||
|
||||
```sh
|
||||
/path/to/your/instance/posts/pinned.json
|
||||
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).
|
||||
|
||||
## TODO
|
||||
|
||||
| Status | Task |
|
||||
|:---------------------------------------------:|:-------------------------------------------------:|
|
||||
|<span style="color:green;text-align:center;">DONE</span>|Code Editor in Admin Panel with saving!|
|
||||
|<span style="color:orange;"> SEMI </span>| Exporting Tar of 'Posts/' Folder|
|
||||
|
||||
### <span style="color:#d42c2c;">Exporting of Folder:</span>
|
||||
|
||||
This for now atleast , only works with Next.JS Production Server `npm install && npm run build && npm start` for reference.
|
||||
Docker Support for now is limited. I've gotten Persistence working. ( On Branch PM2 )
|
||||
If you are writing posts within the Admin-Panel then you are a _lucky piece of shit_ cause there it does that **automatically**
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
## Troubleshooting
|
||||
|
||||
If any issues pop up, please open a Gitea issue with **proper** error reports!
|
||||
### Common Issues
|
||||
|
||||
**Port Already in Use**
|
||||
```bash
|
||||
# Find process using port 3000
|
||||
lsof -i :3000
|
||||
|
||||
# Kill the process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Docker Permission Issues**
|
||||
```bash
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Restart Docker service
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
**Build Errors**
|
||||
```bash
|
||||
# Clear cache and reinstall
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
- Enable gzip compression
|
||||
- Use a CDN for static assets
|
||||
- Optimize images before upload
|
||||
- Enable caching headers
|
||||
|
||||
---
|
||||
|
||||
## Closing Statements
|
||||
## Support
|
||||
|
||||
Developing of this Applet has been really fun! Thanks JavaScript for fucking my ass harder than , eh ... idk , im gay, i cant make jokes about this.
|
||||
Yeah fuck JavaScript, TypeScript is better.
|
||||
### Getting Help
|
||||
- 📖 **Documentation**: Check this post and code comments
|
||||
- 🐛 **Issues**: Report bugs!
|
||||
|
||||
<q style="font-style:italic;">
|
||||
- Thanks Ratta<b>twink</b>o ; 17.05.2025
|
||||
</q>
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/4/48/Gay_Pride_Flag.svg" style="width:50px;height:50px;float:left;"/>
|
||||
### Contributing
|
||||
We welcome contributions! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
### Resources
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [Tailwind CSS Guide](https://tailwindcss.com/docs)
|
||||
- [Markdown Guide](https://www.markdownguide.org/)
|
||||
|
||||
---
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you enjoy using MarkdownBlog and want to support its development, you can now [buy me a coffee](https://coff.ee/rattatwinko)!
|
||||
|
||||
Your support helps keep the project alive and motivates further improvements. Thank you!
|
||||
|
||||
> **Tip:** You can change the coffee link by setting the `NEXT_SOCIAL_BUYMEACOFFEE` variable in your `.env.local` file.
|
||||
>
|
||||
> If you hate the developer do this.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks for choosing MarkdownBlog! If you find it useful, please:
|
||||
|
||||
- ⭐ **Star the repository** on GitHub
|
||||
- 🐛 **Report issues** if you find bugs
|
||||
- 💡 **Suggest features** for improvements
|
||||
- 📢 **Share** with other developers
|
||||
|
||||
---
|
||||
|
||||
## Folder Emoji Technical Note
|
||||
|
||||
- Folder emoji assignments are now stored in `posts/pinned.json` and are read directly by the API on every request.
|
||||
- This means emoji changes are persistent and robust, especially in Docker, where the file system is isolated and predictable.
|
||||
- If you ever see missing emojis, check that your Docker volume is mounted and the JSON file is up to date.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
MarkdownBlog provides a built-in RESTful API to serve post data, handle live updates, and support integrations. The API is used internally by the frontend to fetch posts, stream updates (for live reloads), and manage features like emoji and pin assignments. You can also interact with these endpoints to build custom tools or integrations.
|
||||
|
||||
Key API endpoints include:
|
||||
|
||||
- `/api/posts`: Fetch all blog posts as JSON.
|
||||
- `/api/posts/[slug]`: Fetch a single post by its slug.
|
||||
- `/api/posts/stream`: Server-Sent Events (SSE) endpoint for real-time updates when posts change.
|
||||
- `/api/posts/webhook`: Webhook endpoint to notify the app of external changes (e.g., from CI/CD or scripts).
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
If you have seen this is not very mindfull of browser resources tho.
|
||||
|
||||
|<span style="color:pink;">IS DONE</span>|Task|
|
||||
|-------|----|
|
||||
|<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:-->
|
||||
<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;">
|
||||
|
||||
|
||||
> **Happy Blogging!** 🎉
|
||||
>
|
||||
> *"DEVELOPERS! DEVELOPERS! DEVELOPERS!"* - Steve Ballmer
|
||||
>
|
||||
> <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
22
run-local-backend.sh
Executable 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
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import BadgeButton from './BadgeButton';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
const InfoIcon = (
|
||||
<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() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<BadgeButton
|
||||
label="ABOUT ME"
|
||||
color="#2563eb"
|
||||
icon={InfoIcon}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open('http://' + window.location.hostname + ':80', '_blank');
|
||||
}
|
||||
}}
|
||||
onClick={() => router.push(withBaseUrl('/posts/about'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,26 +6,30 @@ export default function BadgeButton({
|
||||
color = '#2563eb',
|
||||
icon,
|
||||
onClick,
|
||||
labelColor,
|
||||
}: {
|
||||
label: string;
|
||||
color?: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
labelColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
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={{
|
||||
background: color,
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Verdana, Geneva, DejaVu Sans, sans-serif',
|
||||
fontSize: '0.95rem',
|
||||
letterSpacing: '0.08em',
|
||||
fontSize: '0.75rem',
|
||||
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>{label}</span>
|
||||
<span style={{ color: 'white', fontWeight: 'bold' }}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,50 @@
|
||||
'use client';
|
||||
import BadgeButton from './BadgeButton';
|
||||
import AboutButton from './AboutButton';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
const LockIcon = (
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none">
|
||||
<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"/>
|
||||
<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>
|
||||
);
|
||||
|
||||
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="6" r="4" fill="white" stroke="white" strokeWidth="1.5" />
|
||||
<rect x="3" y="13" width="14" height="5" rx="2.5" fill="white" stroke="white" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const InfoIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="9" stroke="white" strokeWidth="2" />
|
||||
<rect x="9" y="8" width="2" height="6" rx="1" fill="white" />
|
||||
<rect x="9" y="5" width="2" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function HeaderButtons() {
|
||||
return (
|
||||
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
|
||||
<a href="/admin" target="_self" rel="noopener noreferrer">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Admin%20Login-000000?style=for-the-badge&logo=lock&logoColor=white&labelColor=8B0000"
|
||||
alt="Admin Login"
|
||||
<div className="flex gap-2 justify-center sm:justify-end">
|
||||
<a
|
||||
href={withBaseUrl('/admin')}
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||
>
|
||||
<BadgeButton
|
||||
label="ADMIN LOGIN"
|
||||
color="#000000"
|
||||
labelColor="#8B0000"
|
||||
icon={LockIcon}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</a>
|
||||
{/* If your server for about me is running on a different port, change the port number here */}
|
||||
<a href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'} target="_self" rel="noopener noreferrer">
|
||||
<img
|
||||
src="https://img.shields.io/badge/About%20Me-000000?style=for-the-badge&logo=account&logoColor=white&labelColor=2563eb"
|
||||
alt="About Me"
|
||||
<a
|
||||
href={withBaseUrl('/posts/about')}
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||
>
|
||||
<BadgeButton
|
||||
label="ABOUT ME"
|
||||
color="#000000"
|
||||
labelColor="#2563eb"
|
||||
icon={PersonIcon}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
82
src/app/MobileNav.tsx
Normal file
82
src/app/MobileNav.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
interface MobileNavProps {
|
||||
blogOwner: string;
|
||||
}
|
||||
|
||||
export default function MobileNav({ blogOwner }: MobileNavProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="fixed top-4 right-4 z-50 p-2 bg-white rounded-lg shadow-lg border border-gray-200"
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={toggleMenu}>
|
||||
<div className="fixed top-0 right-0 w-64 h-full bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-6">{blogOwner}'s Blog</h2>
|
||||
|
||||
<nav className="space-y-4">
|
||||
<a href={withBaseUrl('/')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
|
||||
🏠 Home
|
||||
</a>
|
||||
|
||||
<a href={withBaseUrl('/admin')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
|
||||
🔐 Admin
|
||||
</a>
|
||||
|
||||
<a href={withBaseUrl('/posts/about')} className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors" onClick={toggleMenu}>
|
||||
👤 About Me
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-500">
|
||||
© {new Date().getFullYear()} {blogOwner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/app/about/page.tsx
Normal file
99
src/app/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/admin/MonacoEditor.tsx
Normal file
12
src/app/admin/MonacoEditor.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Editor from "@monaco-editor/react";
|
||||
|
||||
export default function MonacoEditorWrapper(props: any) {
|
||||
return (
|
||||
<Editor
|
||||
height="600px"
|
||||
defaultLanguage="markdown"
|
||||
defaultValue={props.defaultValue || ""}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
622
src/app/admin/editor/page.tsx
Normal file
622
src/app/admin/editor/page.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
interface Post {
|
||||
type: 'post';
|
||||
@@ -26,7 +27,7 @@ type Node = Post | Folder;
|
||||
// Helper to get folder details
|
||||
async function getFolderDetails(path: string): Promise<{ created: string, items: number, size: number, error?: string }> {
|
||||
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');
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
@@ -38,7 +39,7 @@ async function getFolderDetails(path: string): Promise<{ created: string, items:
|
||||
// Helper to get post size and creation date
|
||||
async function getPostSize(slug: string): Promise<{ size: number | null, created: string | null }> {
|
||||
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');
|
||||
const data = await res.json();
|
||||
return { size: data.size, created: data.created };
|
||||
@@ -76,7 +77,7 @@ export default function ManagePage() {
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/posts');
|
||||
const response = await fetch(withBaseUrl('/api/posts'));
|
||||
const data = await response.json();
|
||||
setNodes(data);
|
||||
} catch (error) {
|
||||
@@ -87,7 +88,7 @@ export default function ManagePage() {
|
||||
const handleLogout = () => {
|
||||
setIsAuthenticated(false);
|
||||
localStorage.removeItem('adminAuth');
|
||||
router.push('/admin');
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Get current directory contents
|
||||
@@ -110,7 +111,7 @@ export default function ManagePage() {
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ name: 'Root', path: [] },
|
||||
{ name: '/', path: [] },
|
||||
...currentPath.map((name, idx) => ({
|
||||
name,
|
||||
path: currentPath.slice(0, idx + 1),
|
||||
@@ -140,7 +141,7 @@ export default function ManagePage() {
|
||||
type: deleteConfirm.item.type
|
||||
});
|
||||
|
||||
const response = await fetch('/api/admin/delete', {
|
||||
const response = await fetch(withBaseUrl('/api/admin/delete'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -170,7 +171,7 @@ export default function ManagePage() {
|
||||
// Move post API call
|
||||
const movePost = async (post: Post, targetFolder: string[]) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/posts/move', {
|
||||
const response = await fetch(withBaseUrl('/api/admin/posts/move'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -190,66 +191,92 @@ export default function ManagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch folder details when currentNodes change
|
||||
// Fetch folder details only when navigating to a new directory
|
||||
useEffect(() => {
|
||||
async function fetchDetails() {
|
||||
const details: Record<string, { created: string, items: number, size: number, error?: string }> = {};
|
||||
await Promise.all(currentNodes.filter(n => n.type === 'folder').map(async (folder: any) => {
|
||||
details[folder.path] = await getFolderDetails(folder.path);
|
||||
}));
|
||||
setFolderDetails(details);
|
||||
const folders = currentNodes.filter(n => n.type === 'folder');
|
||||
|
||||
// Only fetch for folders that don't already have details
|
||||
const foldersToFetch = folders.filter((folder: any) => !folderDetails[folder.path]);
|
||||
|
||||
if (foldersToFetch.length > 0) {
|
||||
await Promise.all(foldersToFetch.map(async (folder: any) => {
|
||||
details[folder.path] = await getFolderDetails(folder.path);
|
||||
}));
|
||||
setFolderDetails(prev => ({ ...prev, ...details }));
|
||||
}
|
||||
}
|
||||
fetchDetails();
|
||||
}, [currentNodes]);
|
||||
}, [currentPath]); // Only trigger when path changes, not on every currentNodes change
|
||||
|
||||
// Fetch post sizes and creation dates when currentNodes change
|
||||
// Fetch post sizes only when navigating to a new directory
|
||||
useEffect(() => {
|
||||
async function fetchSizes() {
|
||||
const sizes: Record<string, { size: number | null, created: string | null }> = {};
|
||||
await Promise.all(currentNodes.filter(n => n.type === 'post').map(async (post: any) => {
|
||||
sizes[post.slug] = await getPostSize(post.slug);
|
||||
}));
|
||||
setPostSizes(sizes);
|
||||
const posts = currentNodes.filter(n => n.type === 'post');
|
||||
|
||||
// Only fetch for posts that don't already have sizes
|
||||
const postsToFetch = posts.filter((post: any) => postSizes[post.slug] === undefined);
|
||||
|
||||
if (postsToFetch.length > 0) {
|
||||
await Promise.all(postsToFetch.map(async (post: any) => {
|
||||
sizes[post.slug] = await getPostSize(post.slug);
|
||||
}));
|
||||
setPostSizes(prev => ({ ...prev, ...sizes }));
|
||||
}
|
||||
}
|
||||
fetchSizes();
|
||||
}, [currentNodes]);
|
||||
}, [currentPath]); // Only trigger when path changes, not on every currentNodes change
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold">Manage Content</h1>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
|
||||
{/* 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:items-center gap-3 sm:gap-4">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">Inhaltsverwaltung</h1>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Back to Admin
|
||||
</Link>
|
||||
Zum Admin-Panel
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
title="Inhalt aktualisieren"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs with back button */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{/* Mobile-friendly breadcrumbs */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
||||
{currentPath.length > 0 && (
|
||||
<button
|
||||
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
|
||||
title="Go back one level"
|
||||
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="Einen Ordner zurück"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -259,16 +286,16 @@ export default function ManagePage() {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Back
|
||||
Zurück
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.path.join('/')} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2 text-gray-500">/</span>}
|
||||
{index > 0 && <span className="mx-1 sm:mx-2 text-gray-500">/</span>}
|
||||
<button
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
className={`px-2 py-1 rounded ${
|
||||
className={`px-2 py-1 rounded text-sm sm:text-base ${
|
||||
index === breadcrumbs.length - 1
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'hover:bg-gray-200'
|
||||
@@ -281,13 +308,13 @@ export default function ManagePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Mobile-friendly content grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{currentNodes.map((node) => (
|
||||
node.type === 'folder' ? (
|
||||
<div
|
||||
key={node.name}
|
||||
className={`bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer ${dragOverFolder === node.name ? 'ring-2 ring-blue-400' : ''}`}
|
||||
className={`bg-white p-3 sm:p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer ${dragOverFolder === node.name ? 'ring-2 ring-blue-400' : ''}`}
|
||||
onClick={() => setCurrentPath([...currentPath, node.name])}
|
||||
onDoubleClick={() => setDeleteAllConfirm({ show: true, folder: node })}
|
||||
onDragOver={e => { e.preventDefault(); setDragOverFolder(node.name); }}
|
||||
@@ -301,8 +328,8 @@ export default function ManagePage() {
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold">{node.name}</h3>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm sm:text-base truncate">{node.name}</h3>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{folderDetails[node.path] ? (
|
||||
folderDetails[node.path].error ? (
|
||||
@@ -321,12 +348,12 @@ export default function ManagePage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(node); }}
|
||||
className="text-red-600 hover:text-red-800 p-2"
|
||||
className="text-red-600 hover:text-red-800 p-2 ml-2 flex-shrink-0"
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -342,14 +369,14 @@ export default function ManagePage() {
|
||||
) : (
|
||||
<div
|
||||
key={node.slug}
|
||||
className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
className="bg-white p-3 sm:p-4 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
draggable
|
||||
onDragStart={() => setDraggedPost(node)}
|
||||
onDragEnd={() => setDraggedPost(null)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold">{node.title}</h3>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm sm:text-base truncate">{node.title}</h3>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{postSizes[node.slug] === undefined
|
||||
? <span className="italic">Loading...</span>
|
||||
@@ -366,12 +393,12 @@ export default function ManagePage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(node)}
|
||||
className="text-red-600 hover:text-red-800 p-2"
|
||||
className="text-red-600 hover:text-red-800 p-2 ml-2 flex-shrink-0"
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -388,54 +415,54 @@ export default function ManagePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{/* Mobile-friendly delete confirmation modal */}
|
||||
{deleteConfirm.show && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white p-6 rounded-lg shadow-xl">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<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>
|
||||
<p className="mb-4">
|
||||
Are you sure you want to delete {deleteConfirm.item?.type === 'folder' ? 'folder' : 'post'} "{deleteConfirm.item?.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item?.title}"?
|
||||
<p className="mb-4 text-sm sm:text-base">
|
||||
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>
|
||||
<div className="flex justify-end gap-4">
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: false, item: null })}
|
||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
|
||||
>
|
||||
Cancel
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Full Folder Modal */}
|
||||
{/* Mobile-friendly delete full folder modal */}
|
||||
{deleteAllConfirm.show && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg shadow-xl">
|
||||
<h3 className="text-lg font-bold mb-4">Delete Full Folder</h3>
|
||||
<p className="mb-4">
|
||||
Are you sure you want to <b>delete the entire folder and all its contents</b>?
|
||||
<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">
|
||||
<h3 className="text-lg font-bold mb-4">Lösche Ordner</h3>
|
||||
<p className="mb-4 text-sm sm:text-base">
|
||||
Sind Sie sicher, dass Sie <b>den gesamten Ordner und alle Inhalte löschen</b> möchten?
|
||||
<br />
|
||||
<span className="text-red-600">This cannot be undone!</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-4">
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => setDeleteAllConfirm({ show: false, folder: null })}
|
||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
|
||||
>
|
||||
Cancel
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!deleteAllConfirm.folder) return;
|
||||
// Call delete API with recursive flag
|
||||
await fetch('/api/admin/delete', {
|
||||
await fetch(withBaseUrl('/api/admin/delete'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -448,9 +475,9 @@ export default function ManagePage() {
|
||||
setDeleteAllConfirm({ show: false, folder: null });
|
||||
loadContent();
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
521
src/app/admin/manage/rust-status/page.tsx
Normal file
521
src/app/admin/manage/rust-status/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
interface PostStats {
|
||||
slug: string;
|
||||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
last_interpret_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() {
|
||||
const [stats, setStats] = useState<PostStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [health, setHealth] = useState<HealthReport | 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>('');
|
||||
|
||||
// Summary calculations
|
||||
const totalHits = stats.reduce((sum, s) => sum + s.cache_hits, 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 avgCompile = stats.length ? (stats.reduce((sum, s) => sum + s.last_compile_time_ms, 0) / stats.length).toFixed(1) : 0;
|
||||
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(withBaseUrl('/api/admin/posts?rsparseinfo=1'));
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Statistiken');
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHealth = async () => {
|
||||
setHealthLoading(true);
|
||||
setHealthError(null);
|
||||
try {
|
||||
const res = await fetch(withBaseUrl('/api/admin/posts?checkhealth=1'));
|
||||
if (!res.ok) throw new Error('Fehler beim Laden des Health-Checks');
|
||||
const data = await res.json();
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="w-full max-w-6xl">
|
||||
{/* Header with title and action buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-white rounded-lg shadow-sm p-1.5 flex items-center justify-center">
|
||||
<img
|
||||
className="w-8 h-8 sm:w-10 sm:h-10"
|
||||
src="https://upload.wikimedia.org/wikipedia/commons/d/d5/Rust_programming_language_black_logo.svg"
|
||||
alt="Rust Logo"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold">Rust-Parser Statistiken</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
|
||||
{/* Back to Admin button */}
|
||||
<a
|
||||
href={withBaseUrl('/admin')}
|
||||
className="p-1.5 sm:px-3 sm:py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg shadow-sm flex items-center gap-1 transition-colors text-sm"
|
||||
title="Zurück zur Admin-Panel"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Health Check Section */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm sm:text-base font-semibold mb-2 text-center">Health-Check</h2>
|
||||
{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>}
|
||||
{health && (
|
||||
<div className="bg-gradient-to-br from-gray-50 to-white rounded-lg shadow-sm border border-gray-200 p-4 flex flex-col gap-3 items-center">
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<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>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||||
<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-lg sm:text-xl font-bold text-green-700">{totalHits}</span>
|
||||
<span className="text-xs sm:text-sm text-gray-700 mt-1 text-center font-medium">Cache-Treffer</span>
|
||||
</div>
|
||||
<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-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>
|
||||
|
||||
{/* Log Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={logFilter}
|
||||
onChange={(e) => setLogFilter(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">Alle Stufen</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warnungen</option>
|
||||
<option value="error">Fehler</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Display */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{logsLoading && <div className="text-center py-3 text-sm">Loading logs...</div>}
|
||||
{logsError && <div className="text-red-500 text-center py-3 text-sm">{logsError}</div>}
|
||||
{!logsLoading && !logsError && (
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="text-center py-3 text-gray-500 text-sm">No logs found</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<div key={index} className={`p-3 rounded-lg border-l-4 shadow-sm ${
|
||||
log.level === 'error' ? 'bg-gradient-to-r from-red-50 to-red-100 border-red-400' :
|
||||
log.level === 'warning' ? 'bg-gradient-to-r from-yellow-50 to-yellow-100 border-yellow-400' :
|
||||
'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-400'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`flex-shrink-0 p-1 rounded-full ${
|
||||
log.level === 'error' ? 'bg-red-200 text-red-700' :
|
||||
log.level === 'warning' ? 'bg-yellow-200 text-yellow-700' :
|
||||
'bg-blue-200 text-blue-700'
|
||||
}`}>
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-600 tracking-wide">
|
||||
{new Date(log.timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold tracking-wide ${
|
||||
log.level === 'error' ? 'bg-red-200 text-red-800' :
|
||||
log.level === 'warning' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-blue-200 text-blue-800'
|
||||
}`}>
|
||||
{log.level.toUpperCase()}
|
||||
</span>
|
||||
{log.slug && (
|
||||
<span className="px-2 py-0.5 bg-gray-200 text-gray-700 rounded-full text-xs font-mono tracking-wide">
|
||||
{log.slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 leading-relaxed">{log.message}</div>
|
||||
{log.details && (
|
||||
<div className="text-xs text-gray-600 mt-1 font-mono bg-gray-50 p-2 rounded-md border border-gray-200 leading-relaxed">
|
||||
{log.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,57 +3,33 @@ import { NextResponse } from 'next/server';
|
||||
import { statSync, createReadStream, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// This is the route for exporting posts when using the docker production server
|
||||
// If you try this on the local server, it will fail because the posts directory is not on the local server
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rootDir = process.cwd();
|
||||
const dockerDir = path.join(rootDir, 'docker');
|
||||
const postsDir = path.join(rootDir, 'posts');
|
||||
let tarballName: string;
|
||||
let tarballPath: string;
|
||||
let tarCwd: string;
|
||||
let tarItems: string[];
|
||||
let tarOptions: any = {
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
};
|
||||
const dockerDir = '/app/docker'; // update this to your actual path
|
||||
const tarballName = 'docker-export.tar.gz';
|
||||
const tarballPath = path.join('/tmp', tarballName);
|
||||
|
||||
if (existsSync(dockerDir)) {
|
||||
// Docker is in use: export the entire root directory (excluding node_modules, .next, etc)
|
||||
tarballName = 'root-export.tar.gz';
|
||||
tarballPath = path.join('/tmp', tarballName);
|
||||
tarCwd = rootDir;
|
||||
tarItems = ['.'];
|
||||
tarOptions.file = tarballPath;
|
||||
tarOptions.cwd = tarCwd;
|
||||
tarOptions.filter = (filepath: string) => {
|
||||
// Exclude node_modules, .next, .git, /tmp, and tarball itself
|
||||
const excludes = [
|
||||
'node_modules', '.next', '.git', 'tmp', 'docker.sock', tarballName
|
||||
];
|
||||
// Only check top-level folders/files
|
||||
const rel = filepath.split(path.sep)[0];
|
||||
return !excludes.includes(rel);
|
||||
};
|
||||
} else {
|
||||
// Not docker: export only the posts directory
|
||||
tarballName = 'posts-export.tar.gz';
|
||||
tarballPath = path.join('/tmp', tarballName);
|
||||
tarCwd = rootDir;
|
||||
tarItems = ['posts'];
|
||||
tarOptions.file = tarballPath;
|
||||
tarOptions.cwd = tarCwd;
|
||||
if (!existsSync(dockerDir)) {
|
||||
return NextResponse.json({ error: `${dockerDir} directory does not exist` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create tarball
|
||||
await tar.c(
|
||||
tarOptions,
|
||||
tarItems
|
||||
{
|
||||
gzip: true,
|
||||
file: tarballPath,
|
||||
cwd: path.dirname(dockerDir),
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
},
|
||||
[path.basename(dockerDir)]
|
||||
);
|
||||
|
||||
// Stream the tarball as a response
|
||||
const stat = statSync(tarballPath);
|
||||
const stream = createReadStream(tarballPath);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -63,7 +39,7 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting tarball:', error);
|
||||
return NextResponse.json({ error: 'Error exporting tarball' }, { status: 500 });
|
||||
console.error('Error exporting docker:', error);
|
||||
return NextResponse.json({ error: 'Error exporting docker' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/app/api/admin/exportlocal/route.ts
Normal file
47
src/app/api/admin/exportlocal/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import tar from 'tar';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { statSync, createReadStream, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { gzip } from 'zlib';
|
||||
|
||||
|
||||
// This is the route for exporting posts when using the local production server
|
||||
// If you try this on the docker server, it will fail because the posts directory is not on the docker server
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const localDir = 'posts';
|
||||
const tarballName = 'local-export.tar.gz';
|
||||
const tarballPath = path.join('/tmp', tarballName);
|
||||
|
||||
if (!existsSync(localDir)) {
|
||||
return NextResponse.json({ error: `${localDir} directory does not exist` }, { status: 400 });
|
||||
}
|
||||
|
||||
await tar.c(
|
||||
{
|
||||
gzip: true,
|
||||
file: tarballPath,
|
||||
cwd: path.dirname(localDir),
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
},
|
||||
[path.basename(localDir)]
|
||||
);
|
||||
|
||||
const stat = statSync(tarballPath);
|
||||
const stream = createReadStream(tarballPath);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/gzip',
|
||||
'Content-Disposition': `attachment; filename="${tarballName}"`,
|
||||
'Content-Length': stat.size.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting local:', error);
|
||||
return NextResponse.json({ error: 'Error exporting local' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const postsDirectory = getPostsDirectory();
|
||||
|
||||
@@ -47,15 +48,138 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const info = searchParams.get('rsparseinfo');
|
||||
if (info === '1') {
|
||||
// Call the Rust backend for parser stats
|
||||
const rustResult = spawnSync(
|
||||
process.cwd() + '/markdown_backend/target/release/markdown_backend',
|
||||
['rsparseinfo'],
|
||||
{ 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 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
|
||||
try {
|
||||
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||
console.log('Reading pinned.json from:', pinnedPath);
|
||||
let pinnedData = { pinned: [], folderEmojis: {} };
|
||||
if (fs.existsSync(pinnedPath)) {
|
||||
pinnedData = JSON.parse(fs.readFileSync(pinnedPath, 'utf8'));
|
||||
console.log('Successfully read pinned.json with data:', pinnedData);
|
||||
} else {
|
||||
console.log('pinned.json does not exist, using default data');
|
||||
}
|
||||
return NextResponse.json(pinnedData);
|
||||
} catch (error) {
|
||||
console.error('Error reading pinned.json:', error);
|
||||
return NextResponse.json({ error: 'Error reading pinned.json' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { pinned } = body; // expects an array of slugs
|
||||
if (!Array.isArray(pinned)) {
|
||||
return NextResponse.json({ error: 'Invalid pinned data' }, { status: 400 });
|
||||
const { pinned, folderEmojis } = body; // expects pinned (array) and folderEmojis (object)
|
||||
if (!Array.isArray(pinned) || typeof folderEmojis !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid pinned or folderEmojis data' }, { status: 400 });
|
||||
}
|
||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
||||
fs.writeFileSync(pinnedPath, JSON.stringify(pinned, null, 2), 'utf8');
|
||||
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||
console.log('Saving pinned.json to:', pinnedPath);
|
||||
console.log('Saving data:', { pinned, folderEmojis });
|
||||
fs.writeFileSync(pinnedPath, JSON.stringify({ pinned, folderEmojis }, null, 2), 'utf8');
|
||||
console.log('Successfully saved pinned.json');
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating pinned.json:', error);
|
||||
@@ -86,4 +210,36 @@ export async function PUT(request: Request) {
|
||||
console.error('Error editing post:', error);
|
||||
return NextResponse.json({ error: 'Error editing post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const clearLogs = searchParams.get('clearLogs');
|
||||
|
||||
if (clearLogs === '1') {
|
||||
// Call the Rust backend to clear parser logs
|
||||
const rustResult = spawnSync(
|
||||
process.cwd() + '/markdown_backend/target/release/markdown_backend',
|
||||
['clear-logs'],
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
if (rustResult.status === 0 && rustResult.stdout) {
|
||||
return new Response(rustResult.stdout, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify({ error: rustResult.stderr || rustResult.error }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid delete operation' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
return NextResponse.json({ error: 'Error clearing logs' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,10 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
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 hljs from 'highlight.js';
|
||||
import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const postsDirectory = getPostsDirectory();
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
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,
|
||||
});
|
||||
|
||||
// Function to get file creation date
|
||||
function getFileCreationDate(filePath: string): Date {
|
||||
const stats = fs.statSync(filePath);
|
||||
return stats.birthtime ?? stats.mtime;
|
||||
}
|
||||
|
||||
async function getPostBySlug(slug: string) {
|
||||
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 {
|
||||
// Convert markdown to HTML
|
||||
const rawHtml = marked.parse(content);
|
||||
|
||||
// Create a DOM window for DOMPurify
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
|
||||
// Sanitize the HTML
|
||||
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);
|
||||
// Return a more informative error message in the content
|
||||
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(
|
||||
request: Request,
|
||||
{ params }: { params: { slug: string[] | string } }
|
||||
@@ -95,17 +12,48 @@ export async function GET(
|
||||
try {
|
||||
const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug];
|
||||
const slugPath = slugArr.join('/');
|
||||
const post = await getPostBySlug(slugPath);
|
||||
return NextResponse.json(post);
|
||||
const rustResult = spawnSync(
|
||||
path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend'),
|
||||
['show', slugPath],
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
if (rustResult.status === 0 && rustResult.stdout) {
|
||||
const post = JSON.parse(rustResult.stdout);
|
||||
const fs = require('fs');
|
||||
const filePath = path.join(postsDirectory, slugPath + '.md');
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {}
|
||||
post.raw = raw;
|
||||
post.createdAt = post.created_at;
|
||||
delete post.created_at;
|
||||
return NextResponse.json(post);
|
||||
} else {
|
||||
const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
|
||||
return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading post:', error);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: { slug: string[] | string } }) {
|
||||
try {
|
||||
const { markdown } = await request.json();
|
||||
if (typeof markdown !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid markdown' }, { status: 400 });
|
||||
}
|
||||
const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug];
|
||||
const slugPath = slugArr.join('/');
|
||||
const filePath = path.join(postsDirectory, slugPath + '.md');
|
||||
require('fs').writeFileSync(filePath, markdown, 'utf8');
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error saving file', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
26
src/app/api/posts/preview/route.ts
Normal file
26
src/app/api/posts/preview/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { markdown } = await request.json();
|
||||
if (typeof markdown !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid markdown' }, { status: 400 });
|
||||
}
|
||||
// Call Rust backend with 'render' command, pass markdown via stdin
|
||||
const rustPath = path.resolve(process.cwd(), 'markdown_backend/target/release/markdown_backend');
|
||||
const rustResult = spawnSync(rustPath, ['render'], {
|
||||
input: markdown,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (rustResult.status === 0 && rustResult.stdout) {
|
||||
return NextResponse.json({ html: rustResult.stdout });
|
||||
} else {
|
||||
const rustError = rustResult.stderr || rustResult.error?.toString() || 'Unknown error';
|
||||
return NextResponse.json({ error: 'Rust parser error', details: rustError }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error rendering markdown', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,34 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import createDOMPurify from 'isomorphic-dompurify';
|
||||
import hljs from 'highlight.js';
|
||||
import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||
|
||||
const postsDirectory = getPostsDirectory();
|
||||
|
||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
||||
let pinnedSlugs: string[] = [];
|
||||
if (fs.existsSync(pinnedPath)) {
|
||||
try {
|
||||
pinnedSlugs = JSON.parse(fs.readFileSync(pinnedPath, 'utf8'));
|
||||
} catch {
|
||||
pinnedSlugs = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -44,17 +48,65 @@ marked.setOptions({
|
||||
renderer,
|
||||
});
|
||||
|
||||
async function getPostByPath(filePath: string, relPath: string) {
|
||||
// Replace top-level pinnedData logic with a function
|
||||
function getPinnedData() {
|
||||
const pinnedPath = path.join(process.cwd(), 'posts', 'pinned.json');
|
||||
let pinnedData = { pinned: [], folderEmojis: {} };
|
||||
if (fs.existsSync(pinnedPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(pinnedPath, 'utf8');
|
||||
pinnedData = JSON.parse(raw);
|
||||
if (!pinnedData.pinned) pinnedData.pinned = [];
|
||||
if (!pinnedData.folderEmojis) pinnedData.folderEmojis = {};
|
||||
} catch {
|
||||
pinnedData = { pinned: [], folderEmojis: {} };
|
||||
}
|
||||
}
|
||||
return pinnedData;
|
||||
}
|
||||
|
||||
// Update readPostsDir to accept pinnedData as an argument
|
||||
async function readPostsDir(dir: string, relDir = '', pinnedData: { pinned: string[]; folderEmojis: Record<string, string> }): Promise<any[]> {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const folders: any[] = [];
|
||||
const posts: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip the 'assets' folder
|
||||
if (entry.isDirectory() && entry.name === 'assets' && relDir === '') {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relPath = relDir ? path.join(relDir, entry.name) : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = await readPostsDir(fullPath, relPath, pinnedData);
|
||||
// Debug log for emoji lookup
|
||||
console.log('[FOLDER EMOJI DEBUG]', { relPath, allEmojis: pinnedData.folderEmojis, emoji: pinnedData.folderEmojis[relPath] });
|
||||
const emoji = pinnedData.folderEmojis[relPath] || '📁';
|
||||
folders.push({ type: 'folder', name: entry.name, path: relPath, emoji, children });
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
posts.push(await getPostByPath(fullPath, relPath, pinnedData));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort posts by creation date (newest first)
|
||||
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// Folders first, then posts
|
||||
return [...folders, ...posts];
|
||||
}
|
||||
|
||||
// Update getPostByPath to accept pinnedData
|
||||
async function getPostByPath(filePath: string, relPath: string, pinnedData: { pinned: string[]; folderEmojis: Record<string, string> }) {
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContents);
|
||||
const createdAt = getFileCreationDate(filePath);
|
||||
|
||||
let processedContent = '';
|
||||
try {
|
||||
const rawHtml = marked.parse(content);
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
processedContent = purify.sanitize(rawHtml as string, {
|
||||
const rawHtml = marked.parse(content) as string;
|
||||
processedContent = createDOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
||||
@@ -68,7 +120,7 @@ async function getPostByPath(filePath: string, relPath: string) {
|
||||
'src', 'alt', 'title', 'width', 'height',
|
||||
'frameborder', 'allowfullscreen'
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+\.\-]+(?:[^a-z+\.\-:]|$))/i
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error processing markdown for ${relPath}:`, err);
|
||||
@@ -87,37 +139,15 @@ async function getPostByPath(filePath: string, relPath: string) {
|
||||
summary: data.summary,
|
||||
content: processedContent,
|
||||
createdAt: createdAt.toISOString(),
|
||||
pinned: pinnedSlugs.includes(relPath.replace(/\.md$/, '')),
|
||||
pinned: pinnedData.pinned.includes(relPath.replace(/\.md$/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPostsDir(dir: string, relDir = ''): Promise<any[]> {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const folders: any[] = [];
|
||||
const posts: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relPath = relDir ? path.join(relDir, entry.name) : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = await readPostsDir(fullPath, relPath);
|
||||
folders.push({ type: 'folder', name: entry.name, path: relPath, children });
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
posts.push(await getPostByPath(fullPath, relPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort posts by creation date (newest first)
|
||||
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// Folders first, then posts
|
||||
return [...folders, ...posts];
|
||||
}
|
||||
|
||||
// Update GET handler to use fresh pinnedData
|
||||
export async function GET() {
|
||||
try {
|
||||
const tree = await readPostsDir(postsDirectory);
|
||||
const pinnedData = getPinnedData();
|
||||
const tree = await readPostsDir(postsDirectory, '', pinnedData);
|
||||
return NextResponse.json(tree);
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
|
||||
167
src/app/api/posts/stream/route.ts
Normal file
167
src/app/api/posts/stream/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// Prevent static generation of this route
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// Store connected clients
|
||||
const clients = new Set<ReadableStreamDefaultController>();
|
||||
|
||||
// Handle CORS preflight requests
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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': '*',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/app/api/posts/webhook/route.ts
Normal file
62
src/app/api/posts/webhook/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Prevent static generation of this route
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// Store connected clients for webhook notifications
|
||||
const webhookClients = new Set<{ id: string; controller: ReadableStreamDefaultController }>();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const clientId = searchParams.get('clientId') || Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Add this client to the set
|
||||
webhookClients.add({ id: clientId, controller });
|
||||
|
||||
// Send initial connection message
|
||||
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', clientId, message: 'Webhook connection established' })}\n\n`);
|
||||
|
||||
// Clean up when client disconnects
|
||||
request.signal.addEventListener('abort', () => {
|
||||
webhookClients.delete({ id: clientId, controller });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Webhook endpoint that can be called when files change
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type = 'update', slug } = body;
|
||||
|
||||
// Notify all connected clients
|
||||
const message = JSON.stringify({ type, slug, timestamp: new Date().toISOString() });
|
||||
webhookClients.forEach(({ controller }) => {
|
||||
try {
|
||||
controller.enqueue(`data: ${message}\n\n`);
|
||||
} catch (error) {
|
||||
// Remove disconnected clients
|
||||
webhookClients.delete({ id: '', controller });
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, clientsNotified: webhookClients.size });
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,142 @@
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Prevent horizontal scroll on mobile */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive typography */
|
||||
html {
|
||||
font-size: 16px;
|
||||
/* Improve mobile scrolling */
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
html {
|
||||
font-size: 16px; /* Keep 16px on mobile to prevent zoom */
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced prose styles for mobile reading */
|
||||
.prose {
|
||||
max-width: none;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
margin: 2rem auto;
|
||||
margin: 1.5rem auto;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Enhanced anchor link styles */
|
||||
.prose a[href^="#"] {
|
||||
color: #059669; /* Green color for anchor links */
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.prose a[href^="#"]:hover {
|
||||
color: #047857;
|
||||
text-decoration-thickness: 2px;
|
||||
background-color: #f0fdf4;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Add subtle visual indicator for headings that have anchor links */
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prose h1:hover::before,
|
||||
.prose h2:hover::before,
|
||||
.prose h3:hover::before,
|
||||
.prose h4:hover::before,
|
||||
.prose h5:hover::before,
|
||||
.prose h6:hover::before {
|
||||
content: "🔗";
|
||||
position: absolute;
|
||||
left: -1.5rem;
|
||||
opacity: 0.6;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing for anchor link indicators */
|
||||
@media (min-width: 641px) {
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.875rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Ensure highlight.js styles override Tailwind prose for code blocks */
|
||||
@@ -33,12 +155,304 @@ body {
|
||||
color: inherit !important;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
/* Remove Tailwind's border radius if you want the highlight.js look */
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: #292d3e !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.5em;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Mobile-specific prose adjustments for optimal reading */
|
||||
@media (max-width: 640px) {
|
||||
.prose {
|
||||
font-size: 1rem; /* Larger base font for mobile reading */
|
||||
line-height: 1.7; /* Better line spacing for mobile */
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.3;
|
||||
scroll-margin-top: 100px; /* Account for sticky header */
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3;
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.prose h4, .prose h5, .prose h6 {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
/* Better mobile paragraph spacing */
|
||||
.prose p + p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Improved mobile list spacing */
|
||||
.prose ul, .prose ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Better mobile blockquote */
|
||||
.prose blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 0 1rem 1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop prose optimizations */
|
||||
@media (min-width: 641px) {
|
||||
.prose {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.2;
|
||||
scroll-margin-top: 120px; /* Account for sticky header */
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.3;
|
||||
scroll-margin-top: 120px;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.4;
|
||||
scroll-margin-top: 120px;
|
||||
}
|
||||
|
||||
.prose h4, .prose h5, .prose h6 {
|
||||
scroll-margin-top: 120px;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly focus states and buttons */
|
||||
button:focus,
|
||||
a:focus,
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mobile touch targets - minimum 44px for accessibility */
|
||||
@media (max-width: 640px) {
|
||||
button,
|
||||
a[role="button"],
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Improve touch scrolling */
|
||||
.overflow-auto,
|
||||
.overflow-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-friendly table styles */
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.prose table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
/* Make tables scrollable on mobile */
|
||||
.prose table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific animations */
|
||||
@media (max-width: 640px) {
|
||||
/* Reduce motion for mobile performance */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.animate-spin {
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific container adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific form improvements */
|
||||
@media (max-width: 640px) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px !important; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
/* Improve mobile form spacing */
|
||||
form > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific modal improvements */
|
||||
@media (max-width: 640px) {
|
||||
.fixed.inset-0 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Ensure modals are properly sized on mobile */
|
||||
.fixed.inset-0 > div {
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific grid improvements */
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Ensure cards don't overflow on mobile */
|
||||
.bg-white {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific badge improvements */
|
||||
@media (max-width: 640px) {
|
||||
img[src*="shields.io"] {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile reading optimizations */
|
||||
@media (max-width: 640px) {
|
||||
/* Better text rendering for mobile */
|
||||
.prose {
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-feature-settings: "kern" 1;
|
||||
font-feature-settings: "kern" 1;
|
||||
}
|
||||
|
||||
/* Improved mobile paragraph readability */
|
||||
.prose p {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
/* Better mobile heading spacing */
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mobile-specific code block improvements */
|
||||
.prose pre {
|
||||
border-radius: 0.375rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
/* Mobile-specific link improvements */
|
||||
.prose a {
|
||||
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; }
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import Link from 'next/link';
|
||||
@@ -6,6 +6,8 @@ import Head from 'next/head';
|
||||
import AboutButton from './AboutButton';
|
||||
import BadgeButton from './BadgeButton';
|
||||
import HeaderButtons from './HeaderButtons';
|
||||
import MobileNav from './MobileNav';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@@ -16,6 +18,12 @@ export const metadata: Metadata = {
|
||||
description: `Ein Blog von ${blogOwner}, gebaut mit Next.js und Markdown`,
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
const PersonIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="6" r="4" stroke="white" strokeWidth="2" />
|
||||
@@ -39,53 +47,122 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<Head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" href={withBaseUrl('/favicon.ico')} />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={withBaseUrl('/apple-touch-icon.png')} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={withBaseUrl('/favicon-32x32.png')} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={withBaseUrl('/favicon-16x16.png')} />
|
||||
<link rel="manifest" href={withBaseUrl('/site.webmanifest')} />
|
||||
</Head>
|
||||
<body className="h-full min-h-screen flex flex-col">
|
||||
<body className={`${inter.className} h-full min-h-screen flex flex-col`}>
|
||||
<MobileNav blogOwner={blogOwner} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-gray-100 p-4">
|
||||
<div className="container mx-auto flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex gap-2 justify-center md:justify-start w-full md:w-auto mb-2 md:mb-0">
|
||||
<img src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white" alt="Markdown" />
|
||||
<img src="https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white" alt="Next.js" />
|
||||
<img src="https://img.shields.io/badge/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="Tailwind CSS" />
|
||||
<img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
|
||||
<HeaderButtons />
|
||||
<header className="bg-gray-100 p-3 sm:p-4 shadow-sm">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2 justify-center sm:justify-start">
|
||||
<img
|
||||
src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white"
|
||||
alt="Markdown"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
<img
|
||||
src="https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white"
|
||||
alt="Next.js"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
<img
|
||||
src="https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white"
|
||||
alt="Rust"
|
||||
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
<img
|
||||
src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white"
|
||||
alt="TypeScript"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
<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">
|
||||
<HeaderButtons />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
<footer className="bg-gray-100 p-2 mt-auto">
|
||||
<div className="container mx-auto flex flex-col items-center md:flex-row md:justify-between gap-2">
|
||||
<div className="text-center w-full md:w-auto">
|
||||
<span className="text-gray-500" style={{ fontSize: '12px' }}>
|
||||
© {new Date().getFullYear()} {blogOwner}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
|
||||
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
|
||||
<a href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white" alt="GitHub" />
|
||||
</a>
|
||||
)}
|
||||
{process.env.NEXT_SOCIAL_INSTAGRAM && (
|
||||
<a href={process.env.NEXT_SOCIAL_INSTAGRAM.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white" alt="Instagram" />
|
||||
</a>
|
||||
)}
|
||||
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && (
|
||||
<a href={process.env.NEXT_SOCIAL_TWITTER_LINK.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white" alt="Twitter" />
|
||||
</a>
|
||||
)}
|
||||
<footer className="bg-gray-100 p-3 sm:p-4 mt-auto shadow-inner">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="text-center sm:text-left">
|
||||
<span className="text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} {blogOwner}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:flex flex-wrap gap-2 justify-center sm:justify-end">
|
||||
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
|
||||
<a
|
||||
href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-6 sm:h-8"
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white"
|
||||
alt="GitHub"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{process.env.NEXT_SOCIAL_INSTAGRAM && (
|
||||
<a
|
||||
href={process.env.NEXT_SOCIAL_INSTAGRAM.replace(/(^\"|\"$)/g, '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-6 sm:h-8"
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white"
|
||||
alt="Instagram"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && (
|
||||
<a
|
||||
href={process.env.NEXT_SOCIAL_TWITTER_LINK.replace(/(^\"|\"$)/g, '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-6 sm:h-8"
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white"
|
||||
alt="Twitter"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{process.env.NEXT_SOCIAL_BUYMEACOFFEE && (
|
||||
<a
|
||||
href={process.env.NEXT_SOCIAL_BUYMEACOFFEE.replace(/(^\"|\"$)/g, '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-6 sm:h-8"
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black"
|
||||
alt="BuyMeACoffee"
|
||||
className="h-6 sm:h-8"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
1
src/app/monaco-vim.d.ts
vendored
Normal file
1
src/app/monaco-vim.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module 'monaco-vim';
|
||||
238
src/app/page.tsx
238
src/app/page.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import React from 'react';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
interface Post {
|
||||
type: 'post';
|
||||
@@ -22,6 +23,7 @@ interface Folder {
|
||||
name: string;
|
||||
path: string;
|
||||
children: (Folder | Post)[];
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
type Node = Folder | Post;
|
||||
@@ -30,26 +32,96 @@ export default function Home() {
|
||||
const [tree, setTree] = useState<Node[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get blog owner from env
|
||||
const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous';
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
const interval = setInterval(loadTree, 500);
|
||||
return () => clearInterval(interval);
|
||||
|
||||
// Set up Server-Sent Events for real-time updates (optional)
|
||||
let eventSource: EventSource | null = null;
|
||||
let fallbackInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
const setupSSE = () => {
|
||||
try {
|
||||
eventSource = new EventSource(withBaseUrl('/api/posts/stream'));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'update') {
|
||||
loadTree();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// Fallback to minimal polling if SSE fails
|
||||
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connection established');
|
||||
// Clear any fallback interval if SSE is working
|
||||
if (fallbackInterval) {
|
||||
clearInterval(fallbackInterval);
|
||||
fallbackInterval = null;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to establish SSE connection:', error);
|
||||
// Fallback to minimal polling if SSE is not supported
|
||||
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||||
}
|
||||
};
|
||||
|
||||
setupSSE();
|
||||
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
if (fallbackInterval) {
|
||||
clearInterval(fallbackInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadTree = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/posts');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(withBaseUrl('/api/posts'));
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setTree(data);
|
||||
setLastUpdate(new Date());
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Beiträge:', error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Manual refresh function
|
||||
const handleRefresh = () => {
|
||||
loadTree();
|
||||
};
|
||||
|
||||
// Traverse the tree to the current path
|
||||
const getCurrentNodes = (): Node[] => {
|
||||
let nodes: Node[] = tree;
|
||||
@@ -77,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
|
||||
function collectPosts(nodes: Node[]): Post[] {
|
||||
let posts: Post[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'post') {
|
||||
if (node.type === 'post' && node.slug !== 'about') {
|
||||
posts.push(node);
|
||||
} else if (node.type === 'folder') {
|
||||
posts = posts.concat(collectPosts(node.children));
|
||||
@@ -102,26 +184,60 @@ export default function Home() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
<h1 className="text-4xl font-bold mb-2 md:mb-0">{blogOwner}'s Blog</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suche nach Titel, Tag oder Text..."
|
||||
className="border rounded px-4 py-2 w-full md:w-80"
|
||||
/>
|
||||
<main className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-800 rounded">
|
||||
<strong>Fehler:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile-first header section */}
|
||||
<div className="mb-6 sm:mb-8 space-y-4 sm:space-y-0 sm:flex sm:flex-row sm:gap-4 sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}'s Blog</h1>
|
||||
<div className="w-full sm:w-auto flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suche nach Titel, Tag oder Text..."
|
||||
className="flex-1 sm:w-80 border border-gray-300 rounded-lg px-4 py-3 text-base focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Refresh content"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<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" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last update indicator */}
|
||||
{lastUpdate && (
|
||||
<div className="text-xs text-gray-500 text-center sm:text-left mb-4">
|
||||
Aktualisiert: {lastUpdate.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results Section */}
|
||||
{search.trim() && (
|
||||
<div className="mb-10">
|
||||
<h2 className="text-2xl font-bold mb-4">Suchergebnisse</h2>
|
||||
<div className="grid gap-8">
|
||||
<div className="mb-8 sm:mb-10">
|
||||
<h2 className="text-xl sm:text-2xl font-bold mb-4">Suchergebnisse</h2>
|
||||
<div className="grid gap-4 sm:gap-8">
|
||||
{(() => {
|
||||
const posts = filterPosts(collectPosts(tree));
|
||||
if (posts.length === 0) {
|
||||
return <div className="text-gray-500">Keine Beiträge gefunden.</div>;
|
||||
return <div className="text-gray-500 text-center py-8">Keine Beiträge gefunden.</div>;
|
||||
}
|
||||
return posts.map((post: any) => {
|
||||
// Determine folder path from slug
|
||||
@@ -130,38 +246,38 @@ export default function Home() {
|
||||
folderPath = post.slug.split('/').slice(0, -1).join('/');
|
||||
}
|
||||
return (
|
||||
<article key={post.slug} className="border rounded-lg p-6 hover:shadow-lg transition-shadow relative">
|
||||
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
|
||||
{post.pinned && (
|
||||
<span className="absolute top-4 right-4 text-2xl" title="Pinned">📌</span>
|
||||
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
|
||||
)}
|
||||
<Link href={`/posts/${post.slug}`}>
|
||||
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
|
||||
<Link href={`/posts/${post.slug}`} className="block">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
|
||||
{folderPath && (
|
||||
<div className="text-xs text-gray-400 mb-1">in <span className="font-mono">{folderPath}</span></div>
|
||||
<div className="text-xs text-gray-400 mb-2">in <span className="font-mono">{folderPath}</span></div>
|
||||
)}
|
||||
<div className="text-gray-600 mb-4">
|
||||
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
|
||||
{post.date ? (
|
||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex">
|
||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
||||
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{post.summary}</p>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
{post.tags.map((tag: string) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const isMatch = q && tag.toLowerCase().includes(q);
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className={`bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm ${isMatch ? 'bg-yellow-200 font-bold' : ''}`}
|
||||
className={`bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm ${isMatch ? 'bg-yellow-200 font-bold' : ''}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -176,66 +292,72 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal Content (folders and posts) only if not searching */}
|
||||
{!search.trim() && (
|
||||
<>
|
||||
<nav className="mb-6 text-sm text-gray-600 flex gap-2 items-center">
|
||||
{breadcrumbs.map((bc, idx) => (
|
||||
<span key={bc.name} className="flex items-center">
|
||||
{idx > 0 && <span className="mx-1">/</span>}
|
||||
<button
|
||||
className="hover:underline"
|
||||
onClick={() => setCurrentPath(bc.path)}
|
||||
disabled={idx === breadcrumbs.length - 1}
|
||||
>
|
||||
{bc.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* Mobile-friendly breadcrumbs */}
|
||||
<nav className="mb-4 sm:mb-6 text-sm text-gray-600">
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2 items-center">
|
||||
{breadcrumbs.map((bc, idx) => (
|
||||
<span key={bc.name} className="flex items-center">
|
||||
{idx > 0 && <span className="mx-1 text-gray-400">/</span>}
|
||||
<button
|
||||
className="hover:underline px-1 py-1 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={() => setCurrentPath(bc.path)}
|
||||
disabled={idx === breadcrumbs.length - 1}
|
||||
>
|
||||
{bc.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="grid gap-8">
|
||||
|
||||
<div className="grid gap-4 sm:gap-8">
|
||||
{/* Folders */}
|
||||
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
|
||||
<div
|
||||
key={folder.path}
|
||||
className="border rounded-lg p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||||
>
|
||||
<span className="font-semibold text-lg">📁 {folder.name}</span>
|
||||
<span className="font-semibold text-base sm:text-lg">{folder.emoji || '📁'} {folder.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Posts */}
|
||||
{(() => {
|
||||
const posts = nodes.filter((n) => n.type === 'post');
|
||||
const posts = nodes.filter((n) => n.type === 'post' && n.slug !== 'about');
|
||||
const pinnedPosts = posts.filter((post: any) => post.pinned);
|
||||
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
||||
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
|
||||
<article key={post.slug} className="border rounded-lg p-6 hover:shadow-lg transition-shadow relative">
|
||||
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
|
||||
{post.pinned && (
|
||||
<span className="absolute top-4 right-4 text-2xl" title="Pinned">📌</span>
|
||||
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
|
||||
)}
|
||||
<Link href={`/posts/${post.slug}`}>
|
||||
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
|
||||
<div className="text-gray-600 mb-4">
|
||||
<Link href={`/posts/${post.slug}`} className="block">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
|
||||
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
|
||||
{post.date ? (
|
||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex">
|
||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
||||
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{post.summary}</p>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{stripFrontmatter(post.summary)}</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
{post.tags.map((tag: string) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
className="bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { withBaseUrl } from '@/lib/baseUrl';
|
||||
|
||||
interface Post {
|
||||
slug: string;
|
||||
@@ -12,138 +14,527 @@ interface Post {
|
||||
summary: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
// Add a slugify function that matches Rust's slug::slugify
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036F]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Modal state for zoomed image
|
||||
const [zoomImgSrc, setZoomImgSrc] = useState<string | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(1.5); // Start zoomed in
|
||||
const [imgOffset, setImgOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [imgStart, setImgStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const modalImgRef = useRef<HTMLImageElement>(null);
|
||||
const modalContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Join the slug array to get the full path
|
||||
const slugPath = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
|
||||
const slugPath = params.slug.join('/');
|
||||
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
loadPost();
|
||||
|
||||
// Set up polling for changes
|
||||
const interval = setInterval(loadPost, 2000);
|
||||
// Set up Server-Sent Events for real-time updates
|
||||
let eventSource: EventSource | null = null;
|
||||
let fallbackInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
const setupSSE = () => {
|
||||
try {
|
||||
eventSource = new EventSource(withBaseUrl('/api/posts/stream'));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'update') {
|
||||
loadPost();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
return () => clearInterval(interval);
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// Fallback to minimal polling if SSE fails
|
||||
fallbackInterval = setInterval(loadPost, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connection established');
|
||||
// Clear any fallback interval if SSE is working
|
||||
if (fallbackInterval) {
|
||||
clearInterval(fallbackInterval);
|
||||
fallbackInterval = null;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to establish SSE connection:', error);
|
||||
// Fallback to minimal polling if SSE is not supported
|
||||
fallbackInterval = setInterval(loadPost, 30000); // 30 seconds
|
||||
}
|
||||
};
|
||||
|
||||
setupSSE();
|
||||
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
if (fallbackInterval) {
|
||||
clearInterval(fallbackInterval);
|
||||
}
|
||||
};
|
||||
}, [slugPath]);
|
||||
|
||||
// On post load or update, scroll to anchor in hash if present
|
||||
useEffect(() => {
|
||||
// Scroll to anchor if hash is present
|
||||
const scrollToHash = () => {
|
||||
if (window.location.hash) {
|
||||
const id = window.location.hash.substring(1);
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
// On initial load
|
||||
scrollToHash();
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', scrollToHash);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
// Intercept anchor clicks in rendered markdown to ensure smooth scrolling to headings
|
||||
useEffect(() => {
|
||||
// Find the rendered markdown container
|
||||
const prose = document.querySelector('.prose');
|
||||
if (!prose) return;
|
||||
/**
|
||||
* Handles clicks on anchor links (e.g. Table of Contents links) inside the markdown.
|
||||
* - If the link is an in-page anchor (starts with #), prevent default navigation.
|
||||
* - Try to find an element with the corresponding id and scroll to it.
|
||||
* - If not found, search all headings for one whose text matches the anchor (case-insensitive, ignoring spaces/punctuation).
|
||||
* - If a match is found, scroll to that heading.
|
||||
* - Update the URL hash without reloading the page.
|
||||
*/
|
||||
const handleClick = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
let target = e.target as HTMLElement | null;
|
||||
// Traverse up to find the closest anchor tag
|
||||
while (target && target.tagName !== 'A') {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (target && target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const id = target.getAttribute('href')!.slice(1);
|
||||
let el = document.getElementById(id);
|
||||
if (!el) {
|
||||
// Try to find a heading whose text matches the id (case-insensitive, ignoring spaces/punctuation)
|
||||
const headings = prose.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
const normId = normalize(id);
|
||||
const found = Array.from(headings).find(h => normalize(h.textContent || '') === normId);
|
||||
el = (found as HTMLElement) || null;
|
||||
}
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
prose.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
prose.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(withBaseUrl(`/api/posts/${encodeURIComponent(slugPath)}`));
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setPost(data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Beitrags:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!post) {
|
||||
return <div>Lädt...</div>;
|
||||
// Handle navigation to previous/next posts
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (zoomImgSrc) {
|
||||
setZoomImgSrc(null);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Prevent background scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (zoomImgSrc) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Image modal handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setImgStart({ x: imgOffset.x, y: imgOffset.y });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (dragging && dragStart) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
setImgOffset({
|
||||
x: imgStart.x + deltaX,
|
||||
y: imgStart.y + deltaY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
setDragStart(null);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
setDragging(true);
|
||||
setDragStart({ x: touch.clientX, y: touch.clientY });
|
||||
setImgStart({ x: imgOffset.x, y: imgOffset.y });
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (dragging && dragStart) {
|
||||
const touch = e.touches[0];
|
||||
const deltaX = touch.clientX - dragStart.x;
|
||||
const deltaY = touch.clientY - dragStart.y;
|
||||
setImgOffset({
|
||||
x: imgStart.x + deltaX,
|
||||
y: imgStart.y + deltaY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setDragging(false);
|
||||
setDragStart(null);
|
||||
};
|
||||
|
||||
// Enhanced anchor scrolling logic
|
||||
useEffect(() => {
|
||||
const scrollToElement = (element: HTMLElement) => {
|
||||
const headerHeight = 80; // Approximate header height
|
||||
const elementTop = element.offsetTop - headerHeight;
|
||||
const container = document.documentElement;
|
||||
const containerHeight = container.clientHeight;
|
||||
const scrollTop = container.scrollTop;
|
||||
const elementHeight = element.offsetHeight;
|
||||
|
||||
// Calculate the target scroll position
|
||||
let targetScrollTop = elementTop;
|
||||
|
||||
// If element is taller than viewport, scroll to show the top
|
||||
if (elementHeight > containerHeight) {
|
||||
targetScrollTop = elementTop;
|
||||
} else {
|
||||
// Center the element in the viewport
|
||||
targetScrollTop = elementTop - (containerHeight - elementHeight) / 2;
|
||||
}
|
||||
|
||||
// Ensure we don't scroll past the top
|
||||
targetScrollTop = Math.max(0, targetScrollTop);
|
||||
|
||||
// Smooth scroll to the target position
|
||||
const startTime = performance.now();
|
||||
const startScrollTop = scrollTop;
|
||||
const distance = targetScrollTop - startScrollTop;
|
||||
const duration = Math.min(Math.abs(distance) * 0.5, 1000); // Dynamic duration based on distance
|
||||
|
||||
function performActualScroll(elementTop: number) {
|
||||
const currentTime = performance.now();
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function (ease-out)
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
const currentScrollTop = startScrollTop + distance * easeOut;
|
||||
|
||||
container.scrollTop = currentScrollTop;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(() => performActualScroll(elementTop));
|
||||
} else {
|
||||
// Final adjustment to ensure we're exactly at the target
|
||||
container.scrollTop = targetScrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
requestAnimationFrame(() => performActualScroll(elementTop));
|
||||
} else {
|
||||
container.scrollTop = targetScrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const findAndScrollToElement = (id: string, retryCount: number = 0) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
// Small delay to ensure the element is fully rendered
|
||||
setTimeout(() => scrollToElement(element), 50);
|
||||
} else if (retryCount < 10) {
|
||||
// Retry a few times in case the element hasn't been rendered yet
|
||||
setTimeout(() => findAndScrollToElement(id, retryCount + 1), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle hash in URL on page load
|
||||
const handleHashScroll = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const id = hash.substring(1);
|
||||
findAndScrollToElement(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle anchor clicks
|
||||
const handleAnchorClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
|
||||
event.preventDefault();
|
||||
const href = target.getAttribute('href');
|
||||
if (href) {
|
||||
const id = href.substring(1);
|
||||
findAndScrollToElement(id);
|
||||
|
||||
// Update URL without page reload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.pushState(null, '', href);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
document.addEventListener('click', handleAnchorClick);
|
||||
|
||||
// Handle hash scroll on mount and when post changes
|
||||
if (post) {
|
||||
handleHashScroll();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleAnchorClick);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
// Image click handler for modal
|
||||
useEffect(() => {
|
||||
const handleImgClick = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.tagName === 'IMG' && target.src) {
|
||||
e.preventDefault();
|
||||
setZoomImgSrc(target.src);
|
||||
setImgOffset({ x: 0, y: 0 });
|
||||
setZoomLevel(1.5);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && zoomImgSrc) {
|
||||
setZoomImgSrc(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleImgClick);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleImgClick);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [zoomImgSrc]);
|
||||
|
||||
// Zoom handler for modal
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoomLevel(prev => Math.max(0.5, Math.min(3, prev * delta)));
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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 Beitrag...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="min-h-screen px-4 py-10 mx-auto md:mx-16 rounded-2xl shadow-lg">
|
||||
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
|
||||
← Zurück zu den Beiträgen
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold mb-4 text-left">{post.title}</h1>
|
||||
<div className="text-gray-600 mb-8 text-left">
|
||||
{post.date ? (
|
||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex">
|
||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-8">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
<svg className="w-4 h-4 mr-2" 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>
|
||||
Zurück zur Startseite
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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">Beitrag nicht gefunden</h1>
|
||||
<p className="text-gray-600 mb-6">Der angeforderte Beitrag konnte nicht gefunden werden.</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" 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>
|
||||
Zurück zur Startseite
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
// Process tags
|
||||
const renderTags = (tags: string[]) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/?tag=${encodeURIComponent(tag)}`)}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-lg max-w-full text-left"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 sm:px-4 sm:py-2 sm:text-base"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 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">Zurück zur Startseite</span>
|
||||
<span className="sm:hidden">Zurück</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
|
||||
title="Zurück (ESC)"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl 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">
|
||||
{/* Post Header */}
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm mb-4">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{formatDate(post.date)}
|
||||
<span className="mx-2">•</span>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{post.author}
|
||||
</div>
|
||||
|
||||
{post.summary && (
|
||||
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||||
{post.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{renderTags(post.tags)}
|
||||
</header>
|
||||
|
||||
{/* Post Content */}
|
||||
<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>
|
||||
|
||||
{/* Modal for zoomed image */}
|
||||
{zoomImgSrc && (
|
||||
<div
|
||||
ref={modalContainerRef}
|
||||
className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onWheel={handleWheel}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setZoomImgSrc(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={modalImgRef}
|
||||
src={zoomImgSrc}
|
||||
alt="Zoomed"
|
||||
className="max-w-none select-none"
|
||||
style={{
|
||||
transform: `scale(${zoomLevel}) translate(${imgOffset.x / zoomLevel}px, ${imgOffset.y / zoomLevel}px)`,
|
||||
transition: dragging ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setZoomImgSrc(null)}
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
|
||||
title="Close (ESC)"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/lib/baseUrl.ts
Normal file
16
src/lib/baseUrl.ts
Normal 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;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
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 {
|
||||
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 ${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
|
||||
persistent: true
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -6,7 +6,45 @@ module.exports = {
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
screens: {
|
||||
'xs': '475px',
|
||||
'3xl': '1600px',
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
},
|
||||
fontSize: {
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['1rem', { lineHeight: '1.5rem' }],
|
||||
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
'5xl': ['3rem', { lineHeight: '1' }],
|
||||
'6xl': ['3.75rem', { lineHeight: '1' }],
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'spin-reverse': 'spin 1s linear infinite reverse',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
|
||||
15
tooling/ascii.py
Executable file
15
tooling/ascii.py
Executable 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))
|
||||
Reference in New Issue
Block a user