Merge pull request 'mobile' (#4) from mobile into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/4
This commit is contained in:
112
README.md
112
README.md
@@ -36,41 +36,111 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, an
|
|||||||
```
|
```
|
||||||
markdownblog/
|
markdownblog/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/ # Next.js 14 App Router
|
||||||
│ │ ├── admin/ # Admin dashboard pages
|
│ │ ├── admin/ # Admin dashboard pages
|
||||||
│ │ │ ├── manage/ # Content management interface
|
│ │ │ ├── manage/ # Content management interface
|
||||||
│ │ │ └── page.tsx # Main admin dashboard
|
│ │ │ │ └── page.tsx # Manage posts and folders
|
||||||
│ │ ├── api/ # API routes
|
│ │ │ └── page.tsx # Main admin dashboard
|
||||||
|
│ │ ├── api/ # API routes (Next.js API routes)
|
||||||
│ │ │ ├── admin/ # Admin API endpoints
|
│ │ │ ├── admin/ # Admin API endpoints
|
||||||
│ │ │ │ ├── delete/ # Delete posts/folders
|
│ │ │ │ ├── delete/ # Delete posts/folders
|
||||||
|
│ │ │ │ │ └── route.ts
|
||||||
│ │ │ │ ├── docker/ # Docker detection
|
│ │ │ │ ├── docker/ # Docker detection
|
||||||
│ │ │ │ ├── export/ # Export functionality
|
│ │ │ │ │ └── route.ts
|
||||||
|
│ │ │ │ ├── export/ # Export functionality (Docker)
|
||||||
|
│ │ │ │ │ └── route.ts
|
||||||
|
│ │ │ │ ├── exportlocal/ # Export functionality (Local)
|
||||||
|
│ │ │ │ │ └── route.ts
|
||||||
│ │ │ │ ├── folders/ # Folder management
|
│ │ │ │ ├── folders/ # Folder management
|
||||||
|
│ │ │ │ │ ├── details/ # Folder details API
|
||||||
|
│ │ │ │ │ │ └── route.ts
|
||||||
|
│ │ │ │ │ └── route.ts
|
||||||
│ │ │ │ ├── password/ # Password management
|
│ │ │ │ ├── password/ # Password management
|
||||||
|
│ │ │ │ │ └── route.ts
|
||||||
│ │ │ │ ├── posts/ # Post CRUD operations
|
│ │ │ │ ├── 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
|
│ │ │ │ └── upload/ # File upload handling
|
||||||
|
│ │ │ │ └── route.ts
|
||||||
│ │ │ └── posts/ # Public post API
|
│ │ │ └── posts/ # Public post API
|
||||||
|
│ │ │ ├── [slug]/ # Dynamic post API routes
|
||||||
|
│ │ │ │ └── route.ts
|
||||||
|
│ │ │ └── route.ts # List all posts
|
||||||
│ │ ├── posts/ # Blog post pages
|
│ │ ├── posts/ # Blog post pages
|
||||||
│ │ │ └── [...slug]/ # Dynamic post routing
|
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
|
||||||
│ │ ├── globals.css # Global styles
|
│ │ │ └── page.tsx # Individual post page with anchor linking
|
||||||
│ │ ├── layout.tsx # Root layout
|
│ │ ├── AboutButton.tsx # About page button component
|
||||||
│ │ └── page.tsx # Homepage
|
│ │ ├── BadgeButton.tsx # Badge display component
|
||||||
│ └── lib/
|
│ │ ├── globals.css # Global styles and Tailwind imports
|
||||||
│ ├── markdown.ts # Markdown processing utilities
|
│ │ ├── HeaderButtons.tsx # Header navigation buttons
|
||||||
│ └── postsDirectory.ts # Post directory management
|
│ │ ├── highlight-github.css # Code syntax highlighting styles
|
||||||
├── posts/ # Markdown blog posts
|
│ │ ├── layout.tsx # Root layout with metadata
|
||||||
|
│ │ ├── MobileNav.tsx # Mobile navigation component
|
||||||
|
│ │ └── page.tsx # Homepage with post listing
|
||||||
|
│ └── lib/ # Utility libraries
|
||||||
|
│ ├── markdown.ts # Markdown processing with marked.js
|
||||||
|
│ └── postsDirectory.ts # Post directory management and parsing
|
||||||
|
├── posts/ # Markdown blog posts storage
|
||||||
│ ├── pinned.json # Pinned posts configuration
|
│ ├── pinned.json # Pinned posts configuration
|
||||||
│ ├── welcome.md # Welcome post
|
│ ├── welcome.md # Welcome post with frontmatter
|
||||||
│ └── mdtest.md # Test post
|
│ ├── mdtest.md # Test post with various markdown features
|
||||||
├── public/ # Static assets (favicons, etc.)
|
│ ├── anchor-test.md # Test post for anchor linking
|
||||||
├── electron/ # Desktop app configuration
|
│ └── ii/ # Example nested folder structure
|
||||||
│ └── main.js # Electron main process
|
├── 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
|
├── Dockerfile # Docker container configuration
|
||||||
├── docker.sh # Docker deployment script
|
├── docker.sh # Docker deployment script
|
||||||
├── entrypoint.sh # Container entrypoint
|
├── entrypoint.sh # Container entrypoint script
|
||||||
└── package.json # Dependencies and scripts
|
├── 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
|
||||||
|
|
||||||
|
#### 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 anchor linking support
|
||||||
|
- **`src/app/admin/page.tsx`**: Admin dashboard with content management
|
||||||
|
- **`src/app/admin/manage/page.tsx`**: Advanced content management interface
|
||||||
|
|
||||||
|
#### API Routes
|
||||||
|
- **Post Management**: CRUD operations for blog posts
|
||||||
|
- **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
|
||||||
|
|
||||||
|
#### Utilities
|
||||||
|
- **`src/lib/markdown.ts`**: Markdown processing with syntax highlighting
|
||||||
|
- **`src/lib/postsDirectory.ts`**: File system operations and post parsing
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ Quick Start
|
## ⚡ Quick Start
|
||||||
|
|||||||
@@ -11,23 +11,16 @@ author: Rattatwinko's
|
|||||||
|
|
||||||
* [Overview](#overview)
|
* [Overview](#overview)
|
||||||
* [Philosophy](#philosophy)
|
* [Philosophy](#philosophy)
|
||||||
* [Inline HTML](#html)
|
* [Block Elements](#block-elements)
|
||||||
* [Automatic Escaping for Special Characters](#autoescape)
|
* [Paragraphs and Line Breaks](#paragraphs-and-line-breaks)
|
||||||
* [Block Elements](#block)
|
* [Headers](#headers)
|
||||||
* [Paragraphs and Line Breaks](#p)
|
* [Blockquotes](#blockquotes)
|
||||||
* [Headers](#header)
|
* [Lists](#lists)
|
||||||
* [Blockquotes](#blockquote)
|
* [Code Blocks](#code-blocks)
|
||||||
* [Lists](#list)
|
* [Span Elements](#span-elements)
|
||||||
* [Code Blocks](#precode)
|
* [Links](#links)
|
||||||
* [Horizontal Rules](#hr)
|
* [Emphasis](#emphasis)
|
||||||
* [Span Elements](#span)
|
|
||||||
* [Links](#link)
|
|
||||||
* [Emphasis](#em)
|
|
||||||
* [Code](#code)
|
* [Code](#code)
|
||||||
* [Images](#img)
|
|
||||||
* [Miscellaneous](#misc)
|
|
||||||
* [Backslash Escapes](#backslash)
|
|
||||||
* [Automatic Links](#autolink)
|
|
||||||
|
|
||||||
|
|
||||||
**Note:** This document is itself written using Markdown; you
|
**Note:** This document is itself written using Markdown; you
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ author: Rattatwinko's
|
|||||||
|
|
||||||
# Welcome to the Blog
|
# Welcome to the Blog
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- [Starting Things off!](#starting-things-off)
|
||||||
|
- [Formatting](#formatting)
|
||||||
|
- [Some Technical Information](#some-technical-information)
|
||||||
|
- [Building](#building)
|
||||||
|
- [Administration](#administration)
|
||||||
|
- [Todo List for the Project](#todo)
|
||||||
|
- [Issues with the Project](#issues)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
- [Closing Statements](#closing-statements)
|
||||||
|
|
||||||
|
## Starting Things off!
|
||||||
|
|
||||||
This blog was built as a response to the lack of blogging systems that accept "human readable" formats editable in a terminal emulator.
|
This blog was built as a response to the lack of blogging systems that accept "human readable" formats editable in a terminal emulator.
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
@@ -101,12 +115,10 @@ You can pin a post both in the UI and in the backend of the server.
|
|||||||
| Status | Task |
|
| Status | Task |
|
||||||
|:---------------------------------------------:|:-------------------------------------------------:|
|
|:---------------------------------------------:|:-------------------------------------------------:|
|
||||||
|<span style="color:green;text-align:center;">DONE</span>|Code Editor in Admin Panel with saving!|
|
|<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:green;"> DONE! </span>| Exporting Tar of 'Posts/' Folder|
|
||||||
|
|<span style="color:green;"> DONE! </span>| Mobile Viewing of the Website|
|
||||||
|
|<span style="color:orange;"> TO BE DONE! | Refactoring of the Code (Removing Garbage-Code)
|
||||||
|
|
||||||
### <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 )
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,6 +128,15 @@ If any issues pop up, please open a Gitea issue with **proper** error reports!
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Changelog:
|
||||||
|
|
||||||
|
- Via merging of branch **"mobile"**:
|
||||||
|
- Will have support for mobile devices
|
||||||
|
- Scrolling will be "different" (auto header scroll trough links)
|
||||||
|
- Styling will be responsive!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Closing Statements
|
## Closing Statements
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -19,18 +19,30 @@ const InfoIcon = (
|
|||||||
|
|
||||||
export default function HeaderButtons() {
|
export default function HeaderButtons() {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
|
<div className="flex gap-2 justify-center sm:justify-end">
|
||||||
<a href="/admin" target="_self" rel="noopener noreferrer">
|
<a
|
||||||
|
href="/admin"
|
||||||
|
target="_self"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="h-6 sm:h-8 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/badge/Admin%20Login-000000?style=for-the-badge&logo=lock&logoColor=white&labelColor=8B0000"
|
src="https://img.shields.io/badge/Admin%20Login-000000?style=for-the-badge&logo=lock&logoColor=white&labelColor=8B0000"
|
||||||
alt="Admin Login"
|
alt="Admin Login"
|
||||||
|
className="h-6 sm:h-8"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{/* If your server for about me is running on a different port, change the port number here */}
|
{/* 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">
|
<a
|
||||||
|
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
|
||||||
|
target="_self"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="h-6 sm:h-8 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/badge/About%20Me-000000?style=for-the-badge&logo=account&logoColor=white&labelColor=2563eb"
|
src="https://img.shields.io/badge/About%20Me-000000?style=for-the-badge&logo=account&logoColor=white&labelColor=2563eb"
|
||||||
alt="About Me"
|
alt="About Me"
|
||||||
|
className="h-6 sm:h-8"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
93
src/app/MobileNav.tsx
Normal file
93
src/app/MobileNav.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
>
|
||||||
|
🏠 Home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
>
|
||||||
|
🔐 Admin
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
|
||||||
|
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
>
|
||||||
|
👤 About Me
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -219,37 +219,38 @@ export default function ManagePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="max-w-6xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* Mobile-friendly header */}
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
||||||
<h1 className="text-3xl font-bold">Manage Content</h1>
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold">Manage Content</h1>
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
|
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
|
Back to Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
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"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumbs with back button */}
|
{/* Mobile-friendly breadcrumbs */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
||||||
{currentPath.length > 0 && (
|
{currentPath.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
|
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"
|
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-sm sm:text-base"
|
||||||
title="Go back one level"
|
title="Go back one level"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/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"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -262,13 +263,13 @@ export default function ManagePage() {
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<div key={crumb.path.join('/')} className="flex items-center">
|
<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
|
<button
|
||||||
onClick={() => setCurrentPath(crumb.path)}
|
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
|
index === breadcrumbs.length - 1
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
: 'hover:bg-gray-200'
|
: 'hover:bg-gray-200'
|
||||||
@@ -281,13 +282,13 @@ export default function ManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content List */}
|
{/* Mobile-friendly content grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||||
{currentNodes.map((node) => (
|
{currentNodes.map((node) => (
|
||||||
node.type === 'folder' ? (
|
node.type === 'folder' ? (
|
||||||
<div
|
<div
|
||||||
key={node.name}
|
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])}
|
onClick={() => setCurrentPath([...currentPath, node.name])}
|
||||||
onDoubleClick={() => setDeleteAllConfirm({ show: true, folder: node })}
|
onDoubleClick={() => setDeleteAllConfirm({ show: true, folder: node })}
|
||||||
onDragOver={e => { e.preventDefault(); setDragOverFolder(node.name); }}
|
onDragOver={e => { e.preventDefault(); setDragOverFolder(node.name); }}
|
||||||
@@ -301,8 +302,8 @@ export default function ManagePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-bold">{node.name}</h3>
|
<h3 className="font-bold text-sm sm:text-base truncate">{node.name}</h3>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{folderDetails[node.path] ? (
|
{folderDetails[node.path] ? (
|
||||||
folderDetails[node.path].error ? (
|
folderDetails[node.path].error ? (
|
||||||
@@ -321,12 +322,12 @@ export default function ManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); handleDelete(node); }}
|
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"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/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"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -342,14 +343,14 @@ export default function ManagePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={node.slug}
|
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
|
draggable
|
||||||
onDragStart={() => setDraggedPost(node)}
|
onDragStart={() => setDraggedPost(node)}
|
||||||
onDragEnd={() => setDraggedPost(null)}
|
onDragEnd={() => setDraggedPost(null)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-bold">{node.title}</h3>
|
<h3 className="font-bold text-sm sm:text-base truncate">{node.title}</h3>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{postSizes[node.slug] === undefined
|
{postSizes[node.slug] === undefined
|
||||||
? <span className="italic">Loading...</span>
|
? <span className="italic">Loading...</span>
|
||||||
@@ -366,12 +367,12 @@ export default function ManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(node)}
|
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"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/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"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -388,24 +389,24 @@ export default function ManagePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Mobile-friendly delete confirmation modal */}
|
||||||
{deleteConfirm.show && (
|
{deleteConfirm.show && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white p-6 rounded-lg shadow-xl">
|
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
|
||||||
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
|
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
|
||||||
<p className="mb-4">
|
<p className="mb-4 text-sm sm:text-base">
|
||||||
Are you sure you want to delete {deleteConfirm.item?.type === 'folder' ? 'folder' : 'post'} "{deleteConfirm.item?.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item?.title}"?
|
Are you sure you want to delete {deleteConfirm.item?.type === 'folder' ? 'folder' : 'post'} "{deleteConfirm.item?.type === 'folder' ? deleteConfirm.item.name : deleteConfirm.item?.title}"?
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm({ show: false, item: null })}
|
onClick={() => setDeleteConfirm({ show: false, item: null })}
|
||||||
className="px-4 py-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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={confirmDelete}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -414,20 +415,20 @@ export default function ManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Full Folder Modal */}
|
{/* Mobile-friendly delete full folder modal */}
|
||||||
{deleteAllConfirm.show && (
|
{deleteAllConfirm.show && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white p-6 rounded-lg shadow-xl">
|
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
|
||||||
<h3 className="text-lg font-bold mb-4">Delete Full Folder</h3>
|
<h3 className="text-lg font-bold mb-4">Delete Full Folder</h3>
|
||||||
<p className="mb-4">
|
<p className="mb-4 text-sm sm:text-base">
|
||||||
Are you sure you want to <b>delete the entire folder and all its contents</b>?
|
Are you sure you want to <b>delete the entire folder and all its contents</b>?
|
||||||
<br />
|
<br />
|
||||||
<span className="text-red-600">This cannot be undone!</span>
|
<span className="text-red-600">This cannot be undone!</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteAllConfirm({ show: false, folder: null })}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -448,7 +449,7 @@ export default function ManagePage() {
|
|||||||
setDeleteAllConfirm({ show: false, folder: null });
|
setDeleteAllConfirm({ show: false, folder: null });
|
||||||
loadContent();
|
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
|
Delete All
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -640,15 +640,15 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
|
||||||
{pinFeedback && (
|
{pinFeedback && (
|
||||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded shadow-lg z-50">
|
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-4 sm:px-6 py-2 rounded shadow-lg z-50 text-sm sm:text-base">
|
||||||
{pinFeedback}
|
{pinFeedback}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAuthenticated ? (
|
{!isAuthenticated ? (
|
||||||
<div className="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
|
<div className="max-w-md mx-auto bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
||||||
<h1 className="text-2xl font-bold mb-6">Admin Login</h1>
|
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">Admin Login</h1>
|
||||||
<form onSubmit={handleLogin} className="space-y-4" autoComplete="on">
|
<form onSubmit={handleLogin} className="space-y-4" autoComplete="on">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
@@ -661,7 +661,7 @@ export default function AdminPage() {
|
|||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
@@ -677,14 +677,14 @@ export default function AdminPage() {
|
|||||||
ref={passwordRef}
|
ref={passwordRef}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 text-base font-medium"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
@@ -692,41 +692,44 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* Mobile-friendly header */}
|
||||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
<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 gap-2">
|
<h1 className="text-2xl sm:text-3xl font-bold">Admin Dashboard</h1>
|
||||||
<button
|
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2">
|
||||||
onClick={handleLogout}
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
<button
|
||||||
>
|
onClick={handleLogout}
|
||||||
Logout
|
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-sm sm:text-base font-medium"
|
||||||
</button>
|
>
|
||||||
<button
|
Logout
|
||||||
onClick={() => setShowChangePassword(true)}
|
</button>
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
<button
|
||||||
>
|
onClick={() => setShowChangePassword(true)}
|
||||||
Passwort ändern
|
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium"
|
||||||
</button>
|
>
|
||||||
|
Passwort ändern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/* Docker warning above export button */}
|
{/* Docker warning above export button */}
|
||||||
{isDocker && (
|
{isDocker && (
|
||||||
<div className="mb-2 px-4 py-2 bg-yellow-200 text-yellow-900 rounded border border-yellow-400 font-semibold text-sm text-center">
|
<div className="px-4 py-2 bg-yellow-200 text-yellow-900 rounded border border-yellow-400 font-semibold text-xs sm:text-sm text-center">
|
||||||
<span className="font-bold">Warning:</span> Docker is in use. Exporting will export the entire <span className="font-mono">/app</span> root directory (including all files and folders in the container's root).
|
<span className="font-bold">Warning:</span> Docker is in use. Exporting will export the entire <span className="font-mono">/app</span> root directory (including all files and folders in the container's root).
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleExportTarball}
|
onClick={handleExportTarball}
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm sm:text-base font-medium whitespace-nowrap"
|
||||||
title="Export Docker Posts"
|
title="Export Docker Posts"
|
||||||
>
|
>
|
||||||
Export Posts
|
Export Posts
|
||||||
</button>
|
</button>
|
||||||
{rememberExportChoice && lastExportChoice && (
|
{rememberExportChoice && lastExportChoice && (
|
||||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
<div className="flex items-center gap-1 text-xs text-gray-600 w-full sm:w-auto justify-center sm:justify-start">
|
||||||
<span>💾 {lastExportChoice === 'docker' ? 'Docker' : 'Local'}</span>
|
<span>💾 {lastExportChoice === 'docker' ? 'Docker' : 'Local'}</span>
|
||||||
<button
|
<button
|
||||||
onClick={clearExportChoice}
|
onClick={clearExportChoice}
|
||||||
className="text-red-500 hover:text-red-700"
|
className="text-red-500 hover:text-red-700 p-1"
|
||||||
title="Clear remembered choice"
|
title="Clear remembered choice"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -739,16 +742,16 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Password Change Modal */}
|
{/* Password Change Modal */}
|
||||||
{showChangePassword && (
|
{showChangePassword && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full relative">
|
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-lg max-w-md w-full relative">
|
||||||
<button
|
<button
|
||||||
className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 text-2xl"
|
className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 text-2xl p-2"
|
||||||
onClick={() => setShowChangePassword(false)}
|
onClick={() => setShowChangePassword(false)}
|
||||||
title="Schließen"
|
title="Schließen"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-xl font-bold mb-4">Passwort ändern</h2>
|
<h2 className="text-lg sm:text-xl font-bold mb-4">Passwort ändern</h2>
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Altes Passwort</label>
|
<label className="block text-sm font-medium text-gray-700">Altes Passwort</label>
|
||||||
@@ -756,7 +759,7 @@ export default function AdminPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={changePwOld}
|
value={changePwOld}
|
||||||
onChange={e => setChangePwOld(e.target.value)}
|
onChange={e => setChangePwOld(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
@@ -767,7 +770,7 @@ export default function AdminPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={changePwNew}
|
value={changePwNew}
|
||||||
onChange={e => setChangePwNew(e.target.value)}
|
onChange={e => setChangePwNew(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
@@ -778,7 +781,7 @@ export default function AdminPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={changePwConfirm}
|
value={changePwConfirm}
|
||||||
onChange={e => setChangePwConfirm(e.target.value)}
|
onChange={e => setChangePwConfirm(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
@@ -788,7 +791,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
|
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 text-base font-medium"
|
||||||
>
|
>
|
||||||
Passwort speichern
|
Passwort speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -797,17 +800,17 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Breadcrumbs with back button */}
|
{/* Mobile-friendly breadcrumbs */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
||||||
{currentPath.length > 0 && (
|
{currentPath.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
|
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"
|
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors text-sm sm:text-base"
|
||||||
title="Go back one level"
|
title="Go back one level"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/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"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
@@ -820,13 +823,13 @@ export default function AdminPage() {
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<div key={crumb.path.join('/')} className="flex items-center">
|
<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
|
<button
|
||||||
onClick={() => setCurrentPath(crumb.path)}
|
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
|
index === breadcrumbs.length - 1
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
: 'hover:bg-gray-200'
|
: 'hover:bg-gray-200'
|
||||||
@@ -840,25 +843,25 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show current folder path above post creation form */}
|
{/* Show current folder path above post creation form */}
|
||||||
<div className="mb-2 text-gray-500 text-sm">
|
<div className="mb-2 text-gray-500 text-xs sm:text-sm">
|
||||||
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
|
Current folder: <span className="font-mono">{currentPath.join('/') || 'root'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Folder Form */}
|
{/* Create Folder Form */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
|
||||||
<h2 className="text-2xl font-bold mb-4">Create New Folder</h2>
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Folder</h2>
|
||||||
<form onSubmit={handleCreateFolder} className="flex gap-4">
|
<form onSubmit={handleCreateFolder} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="Folder name"
|
placeholder="Folder name"
|
||||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2"
|
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
className="px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-base font-medium"
|
||||||
>
|
>
|
||||||
Create Folder
|
Create Folder
|
||||||
</button>
|
</button>
|
||||||
@@ -867,7 +870,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Drag and Drop Zone */}
|
{/* Drag and Drop Zone */}
|
||||||
<div
|
<div
|
||||||
className={`mb-8 p-8 border-2 border-dashed rounded-lg text-center ${
|
className={`mb-6 sm:mb-8 p-4 sm:p-8 border-2 border-dashed rounded-lg text-center ${
|
||||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -875,14 +878,14 @@ export default function AdminPage() {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<div className="text-gray-600">
|
<div className="text-gray-600">
|
||||||
<p className="text-lg font-medium">Drag and drop Markdown files here</p>
|
<p className="text-base sm:text-lg font-medium">Drag and drop Markdown files here</p>
|
||||||
<p className="text-sm">Files will be uploaded to: {currentPath.join('/') || 'root'}</p>
|
<p className="text-xs sm:text-sm">Files will be uploaded to: {currentPath.join('/') || 'root'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Post Form */}
|
{/* Create Post Form */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
|
||||||
<h2 className="text-2xl font-bold mb-4">Create New Post</h2>
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Post</h2>
|
||||||
<form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4">
|
<form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Title</label>
|
<label className="block text-sm font-medium text-gray-700">Title</label>
|
||||||
@@ -890,7 +893,7 @@ export default function AdminPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={newPost.title}
|
value={newPost.title}
|
||||||
onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
|
onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -900,7 +903,7 @@ export default function AdminPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={newPost.date}
|
value={newPost.date}
|
||||||
onChange={(e) => setNewPost({ ...newPost, date: e.target.value })}
|
onChange={(e) => setNewPost({ ...newPost, date: e.target.value })}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,7 +913,7 @@ export default function AdminPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={newPost.tags}
|
value={newPost.tags}
|
||||||
onChange={(e) => setNewPost({ ...newPost, tags: e.target.value })}
|
onChange={(e) => setNewPost({ ...newPost, tags: e.target.value })}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
placeholder="tag1, tag2, tag3"
|
placeholder="tag1, tag2, tag3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -919,41 +922,36 @@ export default function AdminPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={newPost.summary}
|
value={newPost.summary}
|
||||||
onChange={(e) => setNewPost({ ...newPost, summary: e.target.value })}
|
onChange={(e) => setNewPost({ ...newPost, summary: e.target.value })}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-base"
|
||||||
rows={2}
|
rows={2}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Labels Row */}
|
{/* Mobile-friendly content editor */}
|
||||||
<div className="flex flex-row gap-4">
|
<div className="space-y-4">
|
||||||
<div className="w-full md:w-1/2 flex items-end h-10">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700">Content (Markdown)</label>
|
<div className="w-full sm:w-1/2">
|
||||||
</div>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content (Markdown)</label>
|
||||||
<div className="w-full md:w-1/2 flex items-end h-10">
|
<textarea
|
||||||
<label className="block text-sm font-medium text-gray-700">Live Preview</label>
|
value={newPost.content}
|
||||||
</div>
|
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
|
||||||
</div>
|
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm sm:text-base"
|
||||||
<div className="flex flex-col md:flex-row gap-4 mt-1">
|
style={{ height: '240px' }}
|
||||||
{/* Markdown Editor */}
|
rows={10}
|
||||||
<div className="w-full md:w-1/2 flex flex-col">
|
required
|
||||||
<textarea
|
/>
|
||||||
value={newPost.content}
|
</div>
|
||||||
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
|
<div className="w-full sm:w-1/2">
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono" style={{ height: '320px' }}
|
<label className="block text-sm font-medium text-gray-700 mb-2">Live Preview</label>
|
||||||
rows={10}
|
<div className="p-3 sm:p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '240px' }}>
|
||||||
required
|
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
{/* Live Markdown Preview */}
|
|
||||||
<div className="w-full md:w-1/2 flex flex-col">
|
|
||||||
<div className="p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '320px' }}>
|
|
||||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{editingPost ? 'Save Changes' : 'Create Post'}
|
{editingPost ? 'Save Changes' : 'Create Post'}
|
||||||
</button>
|
</button>
|
||||||
@@ -961,8 +959,8 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content List */}
|
{/* Content List */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||||
<h2 className="text-2xl font-bold mb-4">Content</h2>
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Content</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Folders */}
|
{/* Folders */}
|
||||||
{currentNodes
|
{currentNodes
|
||||||
@@ -970,12 +968,12 @@ export default function AdminPage() {
|
|||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
<div
|
<div
|
||||||
key={folder.path}
|
key={folder.path}
|
||||||
className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50"
|
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50"
|
||||||
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">📁</span>
|
<span className="text-xl sm:text-2xl">📁</span>
|
||||||
<span className="font-semibold text-lg">{folder.name}</span>
|
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -986,9 +984,9 @@ export default function AdminPage() {
|
|||||||
const pinnedPosts = posts.filter(post => post.pinned);
|
const pinnedPosts = posts.filter(post => post.pinned);
|
||||||
const unpinnedPosts = posts.filter(post => !post.pinned);
|
const unpinnedPosts = posts.filter(post => !post.pinned);
|
||||||
return [...pinnedPosts, ...unpinnedPosts].map((post) => (
|
return [...pinnedPosts, ...unpinnedPosts].map((post) => (
|
||||||
<div key={post.slug} className="border rounded-lg p-4 relative flex flex-col gap-2">
|
<div key={post.slug} className="border rounded-lg p-3 sm:p-4 relative flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||||
<h3 className="text-xl font-semibold flex-1">{post.title}</h3>
|
<h3 className="text-lg sm:text-xl font-semibold flex-1">{post.title}</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Split post.slug into folder and filename
|
// Split post.slug into folder and filename
|
||||||
@@ -997,19 +995,19 @@ export default function AdminPage() {
|
|||||||
const folder = slugParts.length > 1 ? slugParts.slice(0, -1).join('/') : currentPath.join('/');
|
const folder = slugParts.length > 1 ? slugParts.slice(0, -1).join('/') : currentPath.join('/');
|
||||||
loadPostRaw(filename, folder);
|
loadPostRaw(filename, folder);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-lg font-bold shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
|
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
>
|
>
|
||||||
✏️ Edit
|
✏️ Edit
|
||||||
</button>
|
</button>
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
<span title="Angeheftet" className="text-2xl ml-2">📌</span>
|
<span title="Angeheftet" className="text-xl sm:text-2xl">📌</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">{post.date}</p>
|
<p className="text-sm sm:text-base text-gray-600">{post.date}</p>
|
||||||
<p className="text-sm text-gray-500">{post.summary}</p>
|
<p className="text-xs sm:text-sm text-gray-500">{post.summary}</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex flex-wrap gap-1 sm:gap-2">
|
||||||
{(post.tags || []).map((tag) => (
|
{(post.tags || []).map((tag) => (
|
||||||
<span key={tag} className="bg-gray-100 px-2 py-1 rounded text-sm">
|
<span key={tag} className="bg-gray-100 px-2 py-1 rounded text-xs sm:text-sm">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -1020,24 +1018,25 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-12">
|
{/* Mobile-friendly manage content section */}
|
||||||
|
<div className="w-full mt-8 sm:mt-12">
|
||||||
<button
|
<button
|
||||||
className="w-full flex justify-center items-center bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer text-3xl focus:outline-none"
|
className="w-full flex justify-center items-center bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer text-2xl sm:text-3xl focus:outline-none"
|
||||||
onClick={() => setShowManageContent((v) => !v)}
|
onClick={() => setShowManageContent((v) => !v)}
|
||||||
aria-label={showManageContent ? 'Hide Manage Content' : 'Show Manage Content'}
|
aria-label={showManageContent ? 'Hide Manage Content' : 'Show Manage Content'}
|
||||||
>
|
>
|
||||||
<span>{showManageContent ? '▲' : '▼'}</span>
|
<span>{showManageContent ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
{showManageContent && (
|
{showManageContent && (
|
||||||
<div className="mt-4 bg-white p-6 rounded-lg shadow text-center">
|
<div className="mt-4 bg-white p-4 sm:p-6 rounded-lg shadow text-center">
|
||||||
<p className="text-gray-600 mb-2">
|
<p className="text-gray-600 mb-2 text-sm sm:text-base">
|
||||||
Delete posts and folders, manage your content structure
|
Delete posts and folders, manage your content structure
|
||||||
</p>
|
</p>
|
||||||
{/* Folder navigation breadcrumbs */}
|
{/* Folder navigation breadcrumbs */}
|
||||||
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
<div className="flex flex-wrap justify-center gap-1 sm:gap-2 mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setManagePath([])}
|
onClick={() => setManagePath([])}
|
||||||
className={`px-2 py-1 rounded ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
|
className={`px-2 py-1 rounded text-sm sm:text-base ${managePath.length === 0 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
|
||||||
>
|
>
|
||||||
Root
|
Root
|
||||||
</button>
|
</button>
|
||||||
@@ -1045,7 +1044,7 @@ export default function AdminPage() {
|
|||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => setManagePath(managePath.slice(0, idx + 1))}
|
onClick={() => setManagePath(managePath.slice(0, idx + 1))}
|
||||||
className={`px-2 py-1 rounded ${idx === managePath.length - 1 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
|
className={`px-2 py-1 rounded text-sm sm:text-base ${idx === managePath.length - 1 ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-200'}`}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
@@ -1059,8 +1058,8 @@ export default function AdminPage() {
|
|||||||
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
|
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
|
||||||
onClick={() => setManagePath([...managePath, folder.name])}
|
onClick={() => setManagePath([...managePath, folder.name])}
|
||||||
>
|
>
|
||||||
<span className="text-2xl">📁</span>
|
<span className="text-xl sm:text-2xl">📁</span>
|
||||||
<span className="font-semibold text-lg">{folder.name}</span>
|
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,17 +1074,17 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex-1 text-left flex items-center gap-2">
|
<div className="flex-1 text-left flex items-center gap-2">
|
||||||
{pinned.includes(post.slug) && (
|
{pinned.includes(post.slug) && (
|
||||||
<span title="Angeheftet" className="text-xl">📌</span>
|
<span title="Angeheftet" className="text-lg sm:text-xl">📌</span>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{post.title}</div>
|
<div className="font-semibold text-sm sm:text-base">{post.title}</div>
|
||||||
<div className="text-xs text-gray-500">{post.date}</div>
|
<div className="text-xs text-gray-500">{post.date}</div>
|
||||||
<div className="text-xs text-gray-400">{post.summary}</div>
|
<div className="text-xs text-gray-400">{post.summary}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePin(post.slug)}
|
onClick={() => handlePin(post.slug)}
|
||||||
className={`text-2xl focus:outline-none ${pinned.includes(post.slug) ? 'text-yellow-500' : 'text-gray-400 hover:text-yellow-500'}`}
|
className={`text-xl sm:text-2xl focus:outline-none p-1 ${pinned.includes(post.slug) ? 'text-yellow-500' : 'text-gray-400 hover:text-yellow-500'}`}
|
||||||
title={pinned.includes(post.slug) ? 'Lösen' : 'Anheften'}
|
title={pinned.includes(post.slug) ? 'Lösen' : 'Anheften'}
|
||||||
>
|
>
|
||||||
{pinned.includes(post.slug) ? '★' : '☆'}
|
{pinned.includes(post.slug) ? '★' : '☆'}
|
||||||
@@ -1093,7 +1092,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/manage" className="block mt-6 text-blue-600 hover:underline">Go to Content Manager</a>
|
<a href="/admin/manage" className="block mt-6 text-blue-600 hover:underline text-sm sm:text-base">Go to Content Manager</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,28 @@ import { getPostsDirectory } from '@/lib/postsDirectory';
|
|||||||
|
|
||||||
const postsDirectory = getPostsDirectory();
|
const postsDirectory = getPostsDirectory();
|
||||||
|
|
||||||
|
// Function to get file creation date
|
||||||
|
function getFileCreationDate(filePath: string): Date {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
return stats.birthtime ?? stats.mtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to generate ID from text (matches frontend logic)
|
||||||
|
function generateId(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
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) => {
|
renderer.code = (code, infostring, escaped) => {
|
||||||
const lang = (infostring || '').match(/\S*/)?.[0];
|
const lang = (infostring || '').match(/\S*/)?.[0];
|
||||||
const highlighted = lang && hljs.getLanguage(lang)
|
const highlighted = lang && hljs.getLanguage(lang)
|
||||||
@@ -28,12 +49,6 @@ marked.setOptions({
|
|||||||
renderer,
|
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) {
|
async function getPostBySlug(slug: string) {
|
||||||
const realSlug = slug.replace(/\.md$/, '');
|
const realSlug = slug.replace(/\.md$/, '');
|
||||||
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
|
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
|
||||||
|
|||||||
@@ -28,7 +28,22 @@ function getFileCreationDate(filePath: string): Date {
|
|||||||
return stats.birthtime ?? stats.mtime;
|
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();
|
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) => {
|
renderer.code = (code, infostring, escaped) => {
|
||||||
const lang = (infostring || '').match(/\S*/)?.[0];
|
const lang = (infostring || '').match(/\S*/)?.[0];
|
||||||
const highlighted = lang && hljs.getLanguage(lang)
|
const highlighted = lang && hljs.getLanguage(lang)
|
||||||
|
|||||||
@@ -11,20 +11,142 @@
|
|||||||
body {
|
body {
|
||||||
color: rgb(var(--foreground-rgb));
|
color: rgb(var(--foreground-rgb));
|
||||||
background: rgb(var(--background-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 {
|
.prose img {
|
||||||
margin: 2rem auto;
|
margin: 1.5rem auto;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a:hover {
|
.prose a:hover {
|
||||||
color: #1d4ed8;
|
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 */
|
/* Ensure highlight.js styles override Tailwind prose for code blocks */
|
||||||
@@ -33,12 +155,292 @@ body {
|
|||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
/* Remove Tailwind's border radius if you want the highlight.js look */
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose pre {
|
.prose pre {
|
||||||
background: #292d3e !important;
|
background: #292d3e !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5rem;
|
||||||
overflow-x: auto;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -6,6 +6,7 @@ import Head from 'next/head';
|
|||||||
import AboutButton from './AboutButton';
|
import AboutButton from './AboutButton';
|
||||||
import BadgeButton from './BadgeButton';
|
import BadgeButton from './BadgeButton';
|
||||||
import HeaderButtons from './HeaderButtons';
|
import HeaderButtons from './HeaderButtons';
|
||||||
|
import MobileNav from './MobileNav';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ export const metadata: Metadata = {
|
|||||||
description: `Ein Blog von ${blogOwner}, gebaut mit Next.js und Markdown`,
|
description: `Ein Blog von ${blogOwner}, gebaut mit Next.js und Markdown`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 5,
|
||||||
|
};
|
||||||
|
|
||||||
const PersonIcon = (
|
const PersonIcon = (
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||||
<circle cx="10" cy="6" r="4" stroke="white" strokeWidth="2" />
|
<circle cx="10" cy="6" r="4" stroke="white" strokeWidth="2" />
|
||||||
@@ -45,47 +52,96 @@ export default function RootLayout({
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</Head>
|
</Head>
|
||||||
<body className="h-full min-h-screen flex flex-col">
|
<body className={`${inter.className} h-full min-h-screen flex flex-col`}>
|
||||||
<div className="flex-1 flex flex-col">
|
<MobileNav blogOwner={blogOwner} />
|
||||||
<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">
|
<div className="flex-1 flex flex-col">
|
||||||
<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/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white"
|
||||||
|
alt="Tailwind CSS"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:flex gap-2 justify-center sm:justify-end">
|
||||||
|
<HeaderButtons />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<footer className="bg-gray-100 p-2 mt-auto">
|
<footer className="bg-gray-100 p-3 sm:p-4 mt-auto shadow-inner">
|
||||||
<div className="container mx-auto flex flex-col items-center md:flex-row md:justify-between gap-2">
|
<div className="container mx-auto">
|
||||||
<div className="text-center w-full md:w-auto">
|
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
|
||||||
<span className="text-gray-500" style={{ fontSize: '12px' }}>
|
<div className="text-center sm:text-left">
|
||||||
© {new Date().getFullYear()} {blogOwner}
|
<span className="text-gray-500 text-sm">
|
||||||
</span>
|
© {new Date().getFullYear()} {blogOwner}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
|
</div>
|
||||||
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
|
<div className="hidden sm:flex flex-wrap gap-2 justify-center sm:justify-end">
|
||||||
<a href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
|
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
|
||||||
<img src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white" alt="GitHub" />
|
<a
|
||||||
</a>
|
href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')}
|
||||||
)}
|
target="_blank"
|
||||||
{process.env.NEXT_SOCIAL_INSTAGRAM && (
|
rel="noopener noreferrer"
|
||||||
<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" />
|
>
|
||||||
</a>
|
<img
|
||||||
)}
|
src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white"
|
||||||
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && (
|
alt="GitHub"
|
||||||
<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" />
|
/>
|
||||||
</a>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
116
src/app/page.tsx
116
src/app/page.tsx
@@ -102,26 +102,30 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
||||||
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center justify-between">
|
{/* Mobile-first header section */}
|
||||||
<h1 className="text-4xl font-bold mb-2 md:mb-0">{blogOwner}'s Blog</h1>
|
<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">
|
||||||
<input
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}'s Blog</h1>
|
||||||
type="text"
|
<div className="w-full sm:w-auto">
|
||||||
value={search}
|
<input
|
||||||
onChange={e => setSearch(e.target.value)}
|
type="text"
|
||||||
placeholder="Suche nach Titel, Tag oder Text..."
|
value={search}
|
||||||
className="border rounded px-4 py-2 w-full md:w-80"
|
onChange={e => setSearch(e.target.value)}
|
||||||
/>
|
placeholder="Suche nach Titel, Tag oder Text..."
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Results Section */}
|
{/* Search Results Section */}
|
||||||
{search.trim() && (
|
{search.trim() && (
|
||||||
<div className="mb-10">
|
<div className="mb-8 sm:mb-10">
|
||||||
<h2 className="text-2xl font-bold mb-4">Suchergebnisse</h2>
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Suchergebnisse</h2>
|
||||||
<div className="grid gap-8">
|
<div className="grid gap-4 sm:gap-8">
|
||||||
{(() => {
|
{(() => {
|
||||||
const posts = filterPosts(collectPosts(tree));
|
const posts = filterPosts(collectPosts(tree));
|
||||||
if (posts.length === 0) {
|
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) => {
|
return posts.map((post: any) => {
|
||||||
// Determine folder path from slug
|
// Determine folder path from slug
|
||||||
@@ -130,38 +134,38 @@ export default function Home() {
|
|||||||
folderPath = post.slug.split('/').slice(0, -1).join('/');
|
folderPath = post.slug.split('/').slice(0, -1).join('/');
|
||||||
}
|
}
|
||||||
return (
|
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 && (
|
{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}`}>
|
<Link href={`/posts/${post.slug}`} className="block">
|
||||||
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
|
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
|
||||||
{folderPath && (
|
{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 ? (
|
{post.date ? (
|
||||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700 mb-4">{post.summary}</p>
|
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
{post.tags.map((tag: string) => {
|
{post.tags.map((tag: string) => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const isMatch = q && tag.toLowerCase().includes(q);
|
const isMatch = q && tag.toLowerCase().includes(q);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -176,66 +180,72 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Normal Content (folders and posts) only if not searching */}
|
{/* Normal Content (folders and posts) only if not searching */}
|
||||||
{!search.trim() && (
|
{!search.trim() && (
|
||||||
<>
|
<>
|
||||||
<nav className="mb-6 text-sm text-gray-600 flex gap-2 items-center">
|
{/* Mobile-friendly breadcrumbs */}
|
||||||
{breadcrumbs.map((bc, idx) => (
|
<nav className="mb-4 sm:mb-6 text-sm text-gray-600">
|
||||||
<span key={bc.name} className="flex items-center">
|
<div className="flex flex-wrap gap-1 sm:gap-2 items-center">
|
||||||
{idx > 0 && <span className="mx-1">/</span>}
|
{breadcrumbs.map((bc, idx) => (
|
||||||
<button
|
<span key={bc.name} className="flex items-center">
|
||||||
className="hover:underline"
|
{idx > 0 && <span className="mx-1 text-gray-400">/</span>}
|
||||||
onClick={() => setCurrentPath(bc.path)}
|
<button
|
||||||
disabled={idx === breadcrumbs.length - 1}
|
className="hover:underline px-1 py-1 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
onClick={() => setCurrentPath(bc.path)}
|
||||||
{bc.name}
|
disabled={idx === breadcrumbs.length - 1}
|
||||||
</button>
|
>
|
||||||
</span>
|
{bc.name}
|
||||||
))}
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="grid gap-8">
|
|
||||||
|
<div className="grid gap-4 sm:gap-8">
|
||||||
{/* Folders */}
|
{/* Folders */}
|
||||||
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
|
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
|
||||||
<div
|
<div
|
||||||
key={folder.path}
|
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])}
|
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-lg">📁 {folder.name}</span>
|
<span className="font-semibold text-base sm:text-lg">📁 {folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Posts */}
|
{/* Posts */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const posts = nodes.filter((n) => n.type === 'post');
|
const posts = nodes.filter((n) => n.type === 'post');
|
||||||
const pinnedPosts = posts.filter((post: any) => post.pinned);
|
const pinnedPosts = posts.filter((post: any) => post.pinned);
|
||||||
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
|
||||||
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
|
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
|
||||||
<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 && (
|
{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}`}>
|
<Link href={`/posts/${post.slug}`} className="block">
|
||||||
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
|
<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-gray-600 mb-4">
|
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
|
||||||
{post.date ? (
|
{post.date ? (
|
||||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
<span className="text-xl sm:text-2xl animate-spin mr-2">⚙️</span>
|
||||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
<span className="text-xl sm:text-2xl animate-spin-reverse">⚙️</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700 mb-4">{post.summary}</p>
|
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
{post.tags.map((tag: string) => (
|
{post.tags.map((tag: string) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -31,68 +31,465 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [slugPath]);
|
}, [slugPath]);
|
||||||
|
|
||||||
// On post load or update, scroll to anchor in hash if present
|
// Enhanced anchor scrolling logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Scroll to anchor if hash is present
|
if (!post) return;
|
||||||
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
|
// Function to scroll to element using scrollIntoView
|
||||||
useEffect(() => {
|
const scrollToElement = (element: HTMLElement) => {
|
||||||
// Find the rendered markdown container
|
// Get comprehensive element information
|
||||||
const prose = document.querySelector('.prose');
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
if (!prose) return;
|
const windowHeight = window.innerHeight;
|
||||||
/**
|
|
||||||
* Handles clicks on anchor links (e.g. Table of Contents links) inside the markdown.
|
// Detect if we're on desktop or mobile layout
|
||||||
* - If the link is an in-page anchor (starts with #), prevent default navigation.
|
const isDesktop = window.innerWidth >= 640; // sm breakpoint
|
||||||
* - Try to find an element with the corresponding id and scroll to it.
|
const proseContainer = document.querySelector('.prose');
|
||||||
* - 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.
|
console.log('Layout detection:', {
|
||||||
* - Update the URL hash without reloading the page.
|
isDesktop,
|
||||||
*/
|
windowWidth: window.innerWidth,
|
||||||
const handleClick = (e: Event) => {
|
proseContainer: proseContainer ? 'found' : 'not found'
|
||||||
if (!(e instanceof MouseEvent)) return;
|
});
|
||||||
let target = e.target as HTMLElement | null;
|
|
||||||
// Traverse up to find the closest anchor tag
|
// Get the absolute position of the element
|
||||||
while (target && target.tagName !== 'A') {
|
const currentScrollY = window.scrollY;
|
||||||
target = target.parentElement;
|
let elementTop = 0;
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
// For desktop, we need to account for the nested container structure
|
||||||
|
// The content is inside a container with padding and margins
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
elementTop = rect.top + currentScrollY;
|
||||||
|
|
||||||
|
// If we're at the top and getting 0, try a different approach
|
||||||
|
if (elementTop === 0 && currentScrollY === 0) {
|
||||||
|
// Walk up the DOM tree to calculate position
|
||||||
|
let currentElement = element;
|
||||||
|
while (currentElement && currentElement !== document.body) {
|
||||||
|
elementTop += currentElement.offsetTop;
|
||||||
|
currentElement = currentElement.offsetParent as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For mobile, use the simpler approach
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
elementTop = rect.top + currentScrollY;
|
||||||
}
|
}
|
||||||
if (target && target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
|
|
||||||
e.preventDefault();
|
// If we're not at the top, temporarily scroll to top to get accurate positions
|
||||||
const id = target.getAttribute('href')!.slice(1);
|
if (currentScrollY > 0 && elementTop === currentScrollY) {
|
||||||
let el = document.getElementById(id);
|
// Temporarily scroll to top to get accurate element positions
|
||||||
if (!el) {
|
window.scrollTo(0, 0);
|
||||||
// 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');
|
// Wait a moment for the scroll to complete, then measure
|
||||||
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
setTimeout(() => {
|
||||||
const normId = normalize(id);
|
const rect = element.getBoundingClientRect();
|
||||||
const found = Array.from(headings).find(h => normalize(h.textContent || '') === normId);
|
elementTop = rect.top;
|
||||||
el = (found as HTMLElement) || null;
|
|
||||||
|
// Restore original scroll position
|
||||||
|
window.scrollTo(0, currentScrollY);
|
||||||
|
|
||||||
|
// Now perform the actual scroll to the target
|
||||||
|
performActualScroll(elementTop);
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// We're already at the top or have a valid position, get position directly
|
||||||
|
if (elementTop === 0 && currentScrollY === 0) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
elementTop = rect.top;
|
||||||
}
|
}
|
||||||
if (el) {
|
performActualScroll(elementTop);
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
}
|
||||||
history.replaceState(null, '', `#${id}`);
|
|
||||||
|
function performActualScroll(elementTop: number) {
|
||||||
|
console.log('Element details:', {
|
||||||
|
elementText: element.textContent?.substring(0, 50),
|
||||||
|
elementId: element.id,
|
||||||
|
elementTop,
|
||||||
|
currentScrollY: window.scrollY,
|
||||||
|
documentHeight,
|
||||||
|
windowHeight,
|
||||||
|
canScroll: documentHeight > windowHeight,
|
||||||
|
isDesktop
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if page is scrollable
|
||||||
|
if (documentHeight <= windowHeight) {
|
||||||
|
console.warn('Page is not tall enough to scroll');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate the target scroll position with different offsets for desktop/mobile
|
||||||
|
const offset = isDesktop ? 120 : 100; // Slightly more offset for desktop due to header
|
||||||
|
const targetScrollY = Math.max(0, elementTop - offset);
|
||||||
|
|
||||||
|
console.log('Scroll calculation:', {
|
||||||
|
elementTop,
|
||||||
|
targetScrollY,
|
||||||
|
offset,
|
||||||
|
currentScrollY: window.scrollY,
|
||||||
|
scrollDifference: targetScrollY - window.scrollY,
|
||||||
|
isDesktop
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we need to scroll at all
|
||||||
|
if (Math.abs(window.scrollY - targetScrollY) < 10) {
|
||||||
|
console.log('Element already at target position, no scroll needed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a simple, reliable scroll method
|
||||||
|
console.log(`Scrolling from ${window.scrollY} to ${targetScrollY}`);
|
||||||
|
|
||||||
|
// Use requestAnimationFrame for smooth scrolling
|
||||||
|
const startScrollY = window.scrollY;
|
||||||
|
const scrollDistance = targetScrollY - startScrollY;
|
||||||
|
const duration = 500; // 500ms
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animateScroll = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Easing function (ease-out)
|
||||||
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
|
const currentScrollY = startScrollY + (scrollDistance * easeOut);
|
||||||
|
window.scrollTo(0, currentScrollY);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animateScroll);
|
||||||
|
} else {
|
||||||
|
console.log('Scroll animation completed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animateScroll);
|
||||||
|
|
||||||
|
// Log the scroll after a delay to verify it worked
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Scroll verification - new scrollY:', window.scrollY);
|
||||||
|
console.log('Scroll difference:', Math.abs(window.scrollY - targetScrollY));
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
prose.addEventListener('click', handleClick);
|
|
||||||
|
// Function to find and scroll to element with retry
|
||||||
|
const findAndScrollToElement = (id: string, retryCount: number = 0) => {
|
||||||
|
// First check if the content is rendered
|
||||||
|
const proseContent = document.querySelector('.prose');
|
||||||
|
if (!proseContent || !proseContent.innerHTML.trim()) {
|
||||||
|
if (retryCount < 10) {
|
||||||
|
console.log(`Content not yet rendered, retrying... (${retryCount + 1}/10)`);
|
||||||
|
setTimeout(() => {
|
||||||
|
findAndScrollToElement(id, retryCount + 1);
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.warn('Content not rendered after retries');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the element, but only consider visible ones
|
||||||
|
const allElements = document.querySelectorAll(`#${id}`);
|
||||||
|
let element: HTMLElement | null = null;
|
||||||
|
|
||||||
|
// Check if we're on desktop or mobile
|
||||||
|
const isDesktop = window.innerWidth >= 640;
|
||||||
|
|
||||||
|
for (const el of Array.from(allElements)) {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
// Check if the element is visible (not hidden by CSS)
|
||||||
|
const rect = htmlEl.getBoundingClientRect();
|
||||||
|
const isVisible = rect.width > 0 && rect.height > 0;
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
element = htmlEl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
console.log('Found target element:', element.textContent?.substring(0, 50));
|
||||||
|
scrollToElement(element);
|
||||||
|
} else if (retryCount < 5) {
|
||||||
|
console.log(`Element not found for anchor: ${id}, retrying... (${retryCount + 1}/5)`);
|
||||||
|
setTimeout(() => {
|
||||||
|
findAndScrollToElement(id, retryCount + 1);
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
console.warn(`Element with id "${id}" not found after retries`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle anchor link clicks
|
||||||
|
const handleAnchorClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const link = target.closest('a');
|
||||||
|
|
||||||
|
if (!link || !link.getAttribute('href')?.startsWith('#')) return;
|
||||||
|
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
const id = href?.substring(1);
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
console.log('Anchor click detected:', href);
|
||||||
|
|
||||||
|
// Prevent default behavior first
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Find the target element and scroll to it
|
||||||
|
findAndScrollToElement(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle hash-based scrolling on page load
|
||||||
|
const handleHashScroll = () => {
|
||||||
|
if (!window.location.hash) return;
|
||||||
|
|
||||||
|
const id = window.location.hash.substring(1);
|
||||||
|
console.log('Handling hash scroll for:', id);
|
||||||
|
|
||||||
|
// Use a longer delay to ensure DOM is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
findAndScrollToElement(id);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle initial hash scroll
|
||||||
|
handleHashScroll();
|
||||||
|
|
||||||
|
// Add event listener for anchor clicks
|
||||||
|
document.addEventListener('click', handleAnchorClick);
|
||||||
|
|
||||||
|
// Add a test function to the window object for debugging
|
||||||
|
(window as any).testScroll = (id: string) => {
|
||||||
|
console.log('Testing scroll to:', id);
|
||||||
|
findAndScrollToElement(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a function to test basic scrolling
|
||||||
|
(window as any).testBasicScroll = () => {
|
||||||
|
console.log('Testing basic scroll functionality');
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
const testScrollY = currentScrollY + 500;
|
||||||
|
|
||||||
|
console.log('Current scrollY:', currentScrollY);
|
||||||
|
console.log('Target scrollY:', testScrollY);
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: testScrollY,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Basic scroll test completed, new scrollY:', window.scrollY);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a function to test scrolling to a specific element
|
||||||
|
(window as any).testElementScroll = (id: string) => {
|
||||||
|
console.log('Testing scroll to element:', id);
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
console.log('Element found, testing scroll...');
|
||||||
|
scrollToElement(element);
|
||||||
|
} else {
|
||||||
|
console.log('Element not found:', id);
|
||||||
|
(window as any).listIds();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a simple test function
|
||||||
|
(window as any).runAnchorTest = () => {
|
||||||
|
console.log('Running anchor link test...');
|
||||||
|
|
||||||
|
// Test 1: Check if we can find the "overview" heading
|
||||||
|
const overviewElement = document.getElementById('overview');
|
||||||
|
if (overviewElement) {
|
||||||
|
console.log('✅ Found overview element, testing scroll...');
|
||||||
|
scrollToElement(overviewElement);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Overview element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Check if we can find the "test-heading" element
|
||||||
|
setTimeout(() => {
|
||||||
|
const testHeadingElement = document.getElementById('test-heading');
|
||||||
|
if (testHeadingElement) {
|
||||||
|
console.log('✅ Found test-heading element, testing scroll...');
|
||||||
|
scrollToElement(testHeadingElement);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Test-heading element not found');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a desktop-specific test function
|
||||||
|
(window as any).testDesktopScroll = () => {
|
||||||
|
console.log('=== Desktop Scroll Test ===');
|
||||||
|
|
||||||
|
const isDesktop = window.innerWidth >= 640;
|
||||||
|
console.log('Layout detection:', {
|
||||||
|
isDesktop,
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
windowHeight: window.innerHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test scrolling to a known element
|
||||||
|
const overviewElement = document.getElementById('overview');
|
||||||
|
if (overviewElement) {
|
||||||
|
console.log('Testing desktop scroll to overview...');
|
||||||
|
|
||||||
|
// Get element position using desktop method
|
||||||
|
const rect = overviewElement.getBoundingClientRect();
|
||||||
|
const elementTop = rect.top + window.scrollY;
|
||||||
|
|
||||||
|
console.log('Desktop position calculation:', {
|
||||||
|
rectTop: rect.top,
|
||||||
|
currentScrollY: window.scrollY,
|
||||||
|
calculatedElementTop: elementTop
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollToElement(overviewElement);
|
||||||
|
} else {
|
||||||
|
console.log('Overview element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== End Desktop Test ===');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a function to list all available IDs
|
||||||
|
(window as any).listIds = () => {
|
||||||
|
const allIds = Array.from(document.querySelectorAll('[id]')).map(el => ({
|
||||||
|
id: el.id,
|
||||||
|
text: el.textContent?.substring(0, 50),
|
||||||
|
tag: el.tagName
|
||||||
|
}));
|
||||||
|
console.log('Available IDs on page:', allIds);
|
||||||
|
return allIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a function to debug anchor links
|
||||||
|
(window as any).debugAnchors = () => {
|
||||||
|
console.log('=== Anchor Link Debug ===');
|
||||||
|
|
||||||
|
// Get all anchor links
|
||||||
|
const anchorLinks = Array.from(document.querySelectorAll('a[href^="#"]')).map(el => ({
|
||||||
|
href: el.getAttribute('href'),
|
||||||
|
text: el.textContent,
|
||||||
|
targetId: el.getAttribute('href')?.substring(1)
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Anchor links found:', anchorLinks);
|
||||||
|
|
||||||
|
// Get all headings with IDs
|
||||||
|
const headings = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')).map(el => ({
|
||||||
|
id: el.id,
|
||||||
|
text: el.textContent?.substring(0, 50),
|
||||||
|
tag: el.tagName,
|
||||||
|
offsetTop: (el as HTMLElement).offsetTop,
|
||||||
|
getBoundingClientRect: (el as HTMLElement).getBoundingClientRect()
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Headings with IDs:', headings);
|
||||||
|
|
||||||
|
// Check which anchor links have matching headings
|
||||||
|
anchorLinks.forEach(link => {
|
||||||
|
const hasMatch = headings.some(h => h.id === link.targetId);
|
||||||
|
const status = hasMatch ? '✅' : '❌';
|
||||||
|
console.log(`${status} [${link.text}](#${link.targetId}) -> ${hasMatch ? 'FOUND' : 'NOT FOUND'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== End Debug ===');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a function to show element positions
|
||||||
|
(window as any).showPositions = () => {
|
||||||
|
console.log('=== Element Positions ===');
|
||||||
|
|
||||||
|
const headings = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'));
|
||||||
|
|
||||||
|
// Filter to only visible elements
|
||||||
|
const visibleHeadings = headings.filter(el => {
|
||||||
|
const rect = (el as HTMLElement).getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleHeadings.forEach((el, index) => {
|
||||||
|
const element = el as HTMLElement;
|
||||||
|
|
||||||
|
// Calculate absolute position
|
||||||
|
let elementTop = 0;
|
||||||
|
let currentElement = element;
|
||||||
|
while (currentElement && currentElement !== document.body) {
|
||||||
|
elementTop += currentElement.offsetTop;
|
||||||
|
currentElement = currentElement.offsetParent as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${index + 1}. ${element.textContent?.substring(0, 30)}:`);
|
||||||
|
console.log(` ID: ${element.id}`);
|
||||||
|
console.log(` Calculated elementTop: ${elementTop}`);
|
||||||
|
console.log(` element.offsetTop: ${element.offsetTop}`);
|
||||||
|
console.log(` Current scrollY: ${window.scrollY}`);
|
||||||
|
console.log(` Would scroll to: ${Math.max(0, elementTop - 100)}`);
|
||||||
|
console.log('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`=== End Positions (${visibleHeadings.length} visible elements) ===`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a simple test function
|
||||||
|
(window as any).testScrollToElement = (id: string) => {
|
||||||
|
// Find visible element with this ID
|
||||||
|
const allElements = document.querySelectorAll(`#${id}`);
|
||||||
|
let element: HTMLElement | null = null;
|
||||||
|
|
||||||
|
for (const el of Array.from(allElements)) {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
const rect = htmlEl.getBoundingClientRect();
|
||||||
|
const isVisible = rect.width > 0 && rect.height > 0;
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
element = htmlEl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
console.log(`Testing scroll to ${id}...`);
|
||||||
|
|
||||||
|
// Calculate position the same way as scrollToElement
|
||||||
|
let elementTop = 0;
|
||||||
|
let currentElement = element;
|
||||||
|
while (currentElement && currentElement !== document.body) {
|
||||||
|
elementTop += currentElement.offsetTop;
|
||||||
|
currentElement = currentElement.offsetParent as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollY = Math.max(0, elementTop - 100);
|
||||||
|
console.log(`Element ${id} is at position ${elementTop}, would scroll to ${targetScrollY}`);
|
||||||
|
console.log(`Current scroll position: ${window.scrollY}`);
|
||||||
|
|
||||||
|
// Perform the scroll
|
||||||
|
scrollToElement(element);
|
||||||
|
} else {
|
||||||
|
console.log(`Element with id "${id}" not found`);
|
||||||
|
(window as any).listIds();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
prose.removeEventListener('click', handleClick);
|
document.removeEventListener('click', handleAnchorClick);
|
||||||
|
delete (window as any).testScroll;
|
||||||
|
delete (window as any).testBasicScroll;
|
||||||
|
delete (window as any).testElementScroll;
|
||||||
|
delete (window as any).listIds;
|
||||||
|
delete (window as any).debugAnchors;
|
||||||
|
delete (window as any).showPositions;
|
||||||
|
delete (window as any).testScrollToElement;
|
||||||
};
|
};
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
@@ -111,39 +508,106 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-h-screen px-4 py-10 mx-auto md:mx-16 rounded-2xl shadow-lg">
|
<article className="min-h-screen">
|
||||||
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
|
{/* Mobile: Full width, no borders */}
|
||||||
← Zurück zu den Beiträgen
|
<div className="sm:hidden">
|
||||||
</Link>
|
{/* Mobile back button */}
|
||||||
<h1 className="text-4xl font-bold mb-4 text-left">{post.title}</h1>
|
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 px-4 py-3">
|
||||||
<div className="text-gray-600 mb-8 text-left">
|
<Link
|
||||||
{post.date ? (
|
href="/"
|
||||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
) : (
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
{tag}
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
))}
|
</svg>
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile content - full width, optimized for reading */}
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 leading-tight">{post.title}</h1>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 mb-6">
|
||||||
|
{post.date ? (
|
||||||
|
<div className="mb-2">Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="text-lg animate-spin mr-2">⚙️</span>
|
||||||
|
<span className="font-medium">In Bearbeitung</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile-optimized prose content */}
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none prose-headings:scroll-mt-16 prose-p:leading-relaxed prose-p:text-base"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Wider content area with minimal borders */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-w-5xl mx-auto px-8 py-8">
|
||||||
|
{/* Desktop back button */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center text-blue-600 hover:text-blue-800 hover:underline mb-8 text-base font-medium"
|
||||||
|
>
|
||||||
|
<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="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Zurück zu den Beiträgen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop content with minimal border */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-8 lg:p-12">
|
||||||
|
<h1 className="text-3xl lg:text-4xl font-bold mb-6 text-left leading-tight">{post.title}</h1>
|
||||||
|
|
||||||
|
<div className="text-base text-gray-600 mb-8 text-left">
|
||||||
|
{post.date ? (
|
||||||
|
<div className="mb-2">Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="text-xl animate-spin mr-2">⚙️</span>
|
||||||
|
<span className="text-lg font-medium">In Bearbeitung</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap 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"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop-optimized prose content */}
|
||||||
|
<div
|
||||||
|
className="prose prose-lg max-w-none text-left prose-headings:scroll-mt-16 prose-p:leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="prose prose-lg max-w-full text-left"
|
|
||||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,100 @@ function getFileCreationDate(filePath: string): Date {
|
|||||||
return stats.birthtime;
|
return stats.birthtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to generate ID from text (matches frontend logic)
|
||||||
|
function generateId(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced slugification function that matches GitHub-style anchor links
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
||||||
|
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
|
||||||
|
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to process anchor links in markdown content
|
||||||
|
function processAnchorLinks(content: string): string {
|
||||||
|
// Find all markdown links that point to anchors (e.g., [text](#anchor))
|
||||||
|
return content.replace(/\[([^\]]+)\]\(#([^)]+)\)/g, (match, linkText, anchor) => {
|
||||||
|
// Only slugify if the anchor doesn't already look like a slug
|
||||||
|
// This prevents double-processing of already-correct anchor links
|
||||||
|
const isAlreadySlugified = /^[a-z0-9-]+$/.test(anchor);
|
||||||
|
const slugifiedAnchor = isAlreadySlugified ? anchor : slugify(anchor);
|
||||||
|
return `[${linkText}](#${slugifiedAnchor})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to debug anchor links (for development)
|
||||||
|
export function debugAnchorLinks(content: string): void {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
|
||||||
|
console.log('=== Anchor Link Debug Info ===');
|
||||||
|
|
||||||
|
// Extract all headings and their IDs
|
||||||
|
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
const headings: Array<{ level: number; text: string; id: string }> = [];
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = headingRegex.exec(content)) !== null) {
|
||||||
|
const level = match[1].length;
|
||||||
|
const text = match[2].trim();
|
||||||
|
const id = slugify(text);
|
||||||
|
headings.push({ level, text, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generated heading IDs:');
|
||||||
|
headings.forEach(({ level, text, id }) => {
|
||||||
|
console.log(` H${level}: "${text}" -> id="${id}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract all anchor links
|
||||||
|
const anchorLinkRegex = /\[([^\]]+)\]\(#([^)]+)\)/g;
|
||||||
|
const anchorLinks: Array<{ linkText: string; originalAnchor: string; slugifiedAnchor: string }> = [];
|
||||||
|
|
||||||
|
while ((match = anchorLinkRegex.exec(content)) !== null) {
|
||||||
|
const linkText = match[1];
|
||||||
|
const originalAnchor = match[2];
|
||||||
|
const slugifiedAnchor = slugify(originalAnchor);
|
||||||
|
anchorLinks.push({ linkText, originalAnchor, slugifiedAnchor });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Anchor links found:');
|
||||||
|
anchorLinks.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
|
||||||
|
const headingExists = headings.some(h => h.id === slugifiedAnchor);
|
||||||
|
const status = headingExists ? '✅' : '❌';
|
||||||
|
console.log(` ${status} [${linkText}](#${originalAnchor}) -> [${linkText}](#${slugifiedAnchor})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show missing headings
|
||||||
|
const missingAnchors = anchorLinks.filter(({ slugifiedAnchor }) =>
|
||||||
|
!headings.some(h => h.id === slugifiedAnchor)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingAnchors.length > 0) {
|
||||||
|
console.warn('Missing headings for these anchor links:');
|
||||||
|
missingAnchors.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
|
||||||
|
console.warn(` - [${linkText}](#${originalAnchor}) -> id="${slugifiedAnchor}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== End Debug Info ===');
|
||||||
|
}
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
// Custom heading renderer to add IDs
|
||||||
|
renderer.heading = (text, level) => {
|
||||||
|
const id = slugify(text);
|
||||||
|
return `<h${level} id="${id}">${text}</h${level}>`;
|
||||||
|
};
|
||||||
|
|
||||||
renderer.code = (code, infostring, escaped) => {
|
renderer.code = (code, infostring, escaped) => {
|
||||||
const lang = (infostring || '').match(/\S*/)?.[0];
|
const lang = (infostring || '').match(/\S*/)?.[0];
|
||||||
const highlighted = lang && hljs.getLanguage(lang)
|
const highlighted = lang && hljs.getLanguage(lang)
|
||||||
@@ -53,7 +146,12 @@ export async function getPostBySlug(slug: string): Promise<Post> {
|
|||||||
|
|
||||||
let processedContent = '';
|
let processedContent = '';
|
||||||
try {
|
try {
|
||||||
const rawHtml = marked.parse(content);
|
// Debug anchor links in development
|
||||||
|
debugAnchorLinks(content);
|
||||||
|
|
||||||
|
// Process anchor links before parsing markdown
|
||||||
|
const processedMarkdown = processAnchorLinks(content);
|
||||||
|
const rawHtml = marked.parse(processedMarkdown);
|
||||||
const window = new JSDOM('').window;
|
const window = new JSDOM('').window;
|
||||||
const purify = DOMPurify(window);
|
const purify = DOMPurify(window);
|
||||||
processedContent = purify.sanitize(rawHtml as string, {
|
processedContent = purify.sanitize(rawHtml as string, {
|
||||||
|
|||||||
@@ -6,7 +6,45 @@ module.exports = {
|
|||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
theme: {
|
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: [
|
plugins: [
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
|
|||||||
Reference in New Issue
Block a user