Merge pull request 'mobile' (#4) from mobile into main
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:
2025-06-21 20:26:49 +00:00
15 changed files with 1665 additions and 378 deletions

112
README.md
View File

@@ -36,41 +36,111 @@ A modern, feature-rich blog system built with **Next.js 14**, **TypeScript**, an
```
markdownblog/
├── src/
│ ├── app/
│ │ ├── admin/ # Admin dashboard pages
│ │ │ ├── manage/ # Content management interface
│ │ │ └── page.tsx # Main admin dashboard
│ │ ├── api/ # API routes
│ ├── app/ # Next.js 14 App Router
│ │ ├── admin/ # Admin dashboard pages
│ │ │ ├── manage/ # Content management interface
│ │ │ └── page.tsx # Manage posts and folders
│ │ │ └── page.tsx # Main admin dashboard
│ │ ├── api/ # API routes (Next.js API routes)
│ │ │ ├── admin/ # Admin API endpoints
│ │ │ │ ├── delete/ # Delete posts/folders
│ │ │ │ │ └── route.ts
│ │ │ │ ├── docker/ # Docker detection
│ │ │ │ ├── export/ # Export functionality
│ │ │ │ │ └── route.ts
│ │ │ │ ├── export/ # Export functionality (Docker)
│ │ │ │ │ └── route.ts
│ │ │ │ ├── exportlocal/ # Export functionality (Local)
│ │ │ │ │ └── route.ts
│ │ │ │ ├── folders/ # Folder management
│ │ │ │ │ ├── details/ # Folder details API
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ ├── password/ # Password management
│ │ │ │ │ └── route.ts
│ │ │ │ ├── posts/ # Post CRUD operations
│ │ │ │ │ ├── move/ # Move posts between folders
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── raw/ # Get raw post content
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── route.ts
│ │ │ │ │ └── size/ # Get post size info
│ │ │ │ │ └── route.ts
│ │ │ │ └── upload/ # File upload handling
│ │ │ │ └── route.ts
│ │ │ └── posts/ # Public post API
│ │ │ ├── [slug]/ # Dynamic post API routes
│ │ │ │ └── route.ts
│ │ │ └── route.ts # List all posts
│ │ ├── posts/ # Blog post pages
│ │ │ └── [...slug]/ # Dynamic post routing
│ │ ├── globals.css # Global styles
│ │ ├── layout.tsx # Root layout
│ │ ── page.tsx # Homepage
└── lib/
├── markdown.ts # Markdown processing utilities
── postsDirectory.ts # Post directory management
├── posts/ # Markdown blog posts
│ │ │ └── [...slug]/ # Dynamic post routing (catch-all)
│ │ │ └── page.tsx # Individual post page with anchor linking
│ │ ├── AboutButton.tsx # About page button component
│ │ ── BadgeButton.tsx # Badge display component
│ ├── globals.css # Global styles and Tailwind imports
├── HeaderButtons.tsx # Header navigation buttons
── highlight-github.css # Code syntax highlighting styles
│ │ ├── layout.tsx # Root layout with metadata
│ │ ├── MobileNav.tsx # Mobile navigation component
│ │ └── 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
│ ├── welcome.md # Welcome post
── mdtest.md # Test post
├── public/ # Static assets (favicons, etc.)
├── electron/ # Desktop app configuration
│ └── main.js # Electron main process
│ ├── welcome.md # Welcome post with frontmatter
── mdtest.md # Test post with various markdown features
│ ├── anchor-test.md # Test post for anchor linking
│ └── ii/ # Example nested folder structure
├── public/ # Static assets
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ └── site.webmanifest
├── electron/ # Desktop application
│ └── main.js # Electron main process configuration
├── Dockerfile # Docker container configuration
├── docker.sh # Docker deployment script
├── entrypoint.sh # Container entrypoint
── package.json # Dependencies and scripts
├── entrypoint.sh # Container entrypoint script
── 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

View File

@@ -11,23 +11,16 @@ author: Rattatwinko's
* [Overview](#overview)
* [Philosophy](#philosophy)
* [Inline HTML](#html)
* [Automatic Escaping for Special Characters](#autoescape)
* [Block Elements](#block)
* [Paragraphs and Line Breaks](#p)
* [Headers](#header)
* [Blockquotes](#blockquote)
* [Lists](#list)
* [Code Blocks](#precode)
* [Horizontal Rules](#hr)
* [Span Elements](#span)
* [Links](#link)
* [Emphasis](#em)
* [Block Elements](#block-elements)
* [Paragraphs and Line Breaks](#paragraphs-and-line-breaks)
* [Headers](#headers)
* [Blockquotes](#blockquotes)
* [Lists](#lists)
* [Code Blocks](#code-blocks)
* [Span Elements](#span-elements)
* [Links](#links)
* [Emphasis](#emphasis)
* [Code](#code)
* [Images](#img)
* [Miscellaneous](#misc)
* [Backslash Escapes](#backslash)
* [Automatic Links](#autolink)
**Note:** This document is itself written using Markdown; you

View File

@@ -10,6 +10,20 @@ author: Rattatwinko's
# 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.
**Prerequisites:**
@@ -101,12 +115,10 @@ You can pin a post both in the UI and in the backend of the server.
| Status | Task |
|:---------------------------------------------:|:-------------------------------------------------:|
|<span style="color:green;text-align:center;">DONE</span>|Code Editor in Admin Panel with saving!|
|<span style="color:orange;"> SEMI </span>| Exporting Tar of 'Posts/' Folder|
|<span style="color: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
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.

View File

@@ -19,18 +19,30 @@ const InfoIcon = (
export default function HeaderButtons() {
return (
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
<a href="/admin" target="_self" rel="noopener noreferrer">
<div className="flex gap-2 justify-center sm:justify-end">
<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
src="https://img.shields.io/badge/Admin%20Login-000000?style=for-the-badge&logo=lock&logoColor=white&labelColor=8B0000"
alt="Admin Login"
className="h-6 sm:h-8"
/>
</a>
{/* If your server for about me is running on a different port, change the port number here */}
<a href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'} target="_self" rel="noopener noreferrer">
<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
src="https://img.shields.io/badge/About%20Me-000000?style=for-the-badge&logo=account&logoColor=white&labelColor=2563eb"
alt="About Me"
className="h-6 sm:h-8"
/>
</a>
</div>

93
src/app/MobileNav.tsx Normal file
View 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}&apos;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">
&copy; {new Date().getFullYear()} {blogOwner}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -219,37 +219,38 @@ export default function ManagePage() {
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-4">
<h1 className="text-3xl font-bold">Manage Content</h1>
{/* Mobile-friendly header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Manage Content</h1>
<Link
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
</Link>
</div>
<button
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
</button>
</div>
{/* Breadcrumbs with back button */}
<div className="flex items-center gap-4 mb-6">
{/* Mobile-friendly breadcrumbs */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
{currentPath.length > 0 && (
<button
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
className="h-4 w-4 sm:h-5 sm:w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -262,13 +263,13 @@ export default function ManagePage() {
Back
</button>
)}
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path.join('/')} className="flex items-center">
{index > 0 && <span className="mx-2 text-gray-500">/</span>}
{index > 0 && <span className="mx-1 sm:mx-2 text-gray-500">/</span>}
<button
onClick={() => setCurrentPath(crumb.path)}
className={`px-2 py-1 rounded ${
className={`px-2 py-1 rounded text-sm sm:text-base ${
index === breadcrumbs.length - 1
? 'bg-blue-100 text-blue-800'
: 'hover:bg-gray-200'
@@ -281,13 +282,13 @@ export default function ManagePage() {
</div>
</div>
{/* Content List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Mobile-friendly content grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{currentNodes.map((node) => (
node.type === 'folder' ? (
<div
key={node.name}
className={`bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer ${dragOverFolder === node.name ? 'ring-2 ring-blue-400' : ''}`}
className={`bg-white p-3 sm:p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer ${dragOverFolder === node.name ? 'ring-2 ring-blue-400' : ''}`}
onClick={() => setCurrentPath([...currentPath, node.name])}
onDoubleClick={() => setDeleteAllConfirm({ show: true, folder: node })}
onDragOver={e => { e.preventDefault(); setDragOverFolder(node.name); }}
@@ -301,8 +302,8 @@ export default function ManagePage() {
}}
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold">{node.name}</h3>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-sm sm:text-base truncate">{node.name}</h3>
<div className="text-xs text-gray-500 mt-1">
{folderDetails[node.path] ? (
folderDetails[node.path].error ? (
@@ -321,12 +322,12 @@ export default function ManagePage() {
</div>
<button
onClick={e => { e.stopPropagation(); handleDelete(node); }}
className="text-red-600 hover:text-red-800 p-2"
className="text-red-600 hover:text-red-800 p-2 ml-2 flex-shrink-0"
title="Delete"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
className="h-4 w-4 sm:h-5 sm:w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -342,14 +343,14 @@ export default function ManagePage() {
) : (
<div
key={node.slug}
className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow"
className="bg-white p-3 sm:p-4 rounded-lg shadow hover:shadow-md transition-shadow"
draggable
onDragStart={() => setDraggedPost(node)}
onDragEnd={() => setDraggedPost(null)}
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold">{node.title}</h3>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-sm sm:text-base truncate">{node.title}</h3>
<div className="text-xs text-gray-500 mt-1">
{postSizes[node.slug] === undefined
? <span className="italic">Loading...</span>
@@ -366,12 +367,12 @@ export default function ManagePage() {
</div>
<button
onClick={() => handleDelete(node)}
className="text-red-600 hover:text-red-800 p-2"
className="text-red-600 hover:text-red-800 p-2 ml-2 flex-shrink-0"
title="Delete"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
className="h-4 w-4 sm:h-5 sm:w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -388,24 +389,24 @@ export default function ManagePage() {
))}
</div>
{/* Delete Confirmation Modal */}
{/* Mobile-friendly delete confirmation modal */}
{deleteConfirm.show && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-xl">
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="mb-4">
<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}"?
</p>
<div className="flex justify-end gap-4">
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
<button
onClick={() => setDeleteConfirm({ show: false, item: null })}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
>
Delete
</button>
@@ -414,20 +415,20 @@ export default function ManagePage() {
</div>
)}
{/* Delete Full Folder Modal */}
{/* Mobile-friendly delete full folder modal */}
{deleteAllConfirm.show && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl">
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-xl max-w-sm w-full">
<h3 className="text-lg font-bold mb-4">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>?
<br />
<span className="text-red-600">This cannot be undone!</span>
</p>
<div className="flex justify-end gap-4">
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4">
<button
onClick={() => setDeleteAllConfirm({ show: false, folder: null })}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
className="px-4 py-3 sm:py-2 bg-gray-200 rounded hover:bg-gray-300 text-base font-medium"
>
Cancel
</button>
@@ -448,7 +449,7 @@ export default function ManagePage() {
setDeleteAllConfirm({ show: false, folder: null });
loadContent();
}}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
>
Delete All
</button>

View File

@@ -640,15 +640,15 @@ export default function AdminPage() {
};
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 && (
<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}
</div>
)}
{!isAuthenticated ? (
<div className="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-6">Admin Login</h1>
<div className="max-w-md mx-auto bg-white p-4 sm:p-8 rounded-lg shadow-md">
<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">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
@@ -661,7 +661,7 @@ export default function AdminPage() {
ref={usernameRef}
value={username}
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
autoComplete="username"
/>
@@ -677,14 +677,14 @@ export default function AdminPage() {
ref={passwordRef}
value={password}
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
autoComplete="current-password"
/>
</div>
<button
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
</button>
@@ -692,41 +692,44 @@ export default function AdminPage() {
</div>
) : (
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<div className="flex gap-2">
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Logout
</button>
<button
onClick={() => setShowChangePassword(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Passwort ändern
</button>
{/* Mobile-friendly header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<h1 className="text-2xl sm:text-3xl font-bold">Admin Dashboard</h1>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={handleLogout}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-sm sm:text-base font-medium"
>
Logout
</button>
<button
onClick={() => setShowChangePassword(true)}
className="w-full sm:w-auto px-4 py-3 sm:py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium"
>
Passwort ändern
</button>
</div>
{/* Docker warning above export button */}
{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).
</div>
)}
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-center gap-2">
<button
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"
>
Export Posts
</button>
{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>
<button
onClick={clearExportChoice}
className="text-red-500 hover:text-red-700"
className="text-red-500 hover:text-red-700 p-1"
title="Clear remembered choice"
>
×
@@ -739,16 +742,16 @@ export default function AdminPage() {
{/* Password Change Modal */}
{showChangePassword && (
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full relative">
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50 p-4">
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-lg max-w-md w-full relative">
<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)}
title="Schließen"
>
×
</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">
<div>
<label className="block text-sm font-medium text-gray-700">Altes Passwort</label>
@@ -756,7 +759,7 @@ export default function AdminPage() {
type="password"
value={changePwOld}
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
autoComplete="current-password"
/>
@@ -767,7 +770,7 @@ export default function AdminPage() {
type="password"
value={changePwNew}
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
autoComplete="new-password"
/>
@@ -778,7 +781,7 @@ export default function AdminPage() {
type="password"
value={changePwConfirm}
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
autoComplete="new-password"
/>
@@ -788,7 +791,7 @@ export default function AdminPage() {
)}
<button
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
</button>
@@ -797,17 +800,17 @@ export default function AdminPage() {
</div>
)}
{/* Breadcrumbs with back button */}
<div className="flex items-center gap-4 mb-6">
{/* Mobile-friendly breadcrumbs */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
{currentPath.length > 0 && (
<button
onClick={() => setCurrentPath(currentPath.slice(0, -1))}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors"
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
className="h-4 w-4 sm:h-5 sm:w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -820,13 +823,13 @@ export default function AdminPage() {
Back
</button>
)}
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path.join('/')} className="flex items-center">
{index > 0 && <span className="mx-2 text-gray-500">/</span>}
{index > 0 && <span className="mx-1 sm:mx-2 text-gray-500">/</span>}
<button
onClick={() => setCurrentPath(crumb.path)}
className={`px-2 py-1 rounded ${
className={`px-2 py-1 rounded text-sm sm:text-base ${
index === breadcrumbs.length - 1
? 'bg-blue-100 text-blue-800'
: 'hover:bg-gray-200'
@@ -840,25 +843,25 @@ export default function AdminPage() {
</div>
{/* 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>
</div>
{/* Create Folder Form */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex gap-4">
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Folder</h2>
<form onSubmit={handleCreateFolder} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Folder name"
className="flex-1 rounded-md border border-gray-300 px-3 py-2"
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-base"
required
/>
<button
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
</button>
@@ -867,7 +870,7 @@ export default function AdminPage() {
{/* Drag and Drop Zone */}
<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'
}`}
onDragOver={handleDragOver}
@@ -875,14 +878,14 @@ export default function AdminPage() {
onDrop={handleDrop}
>
<div className="text-gray-600">
<p className="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-base sm:text-lg font-medium">Drag and drop Markdown files here</p>
<p className="text-xs sm:text-sm">Files will be uploaded to: {currentPath.join('/') || 'root'}</p>
</div>
</div>
{/* Create Post Form */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-4">Create New Post</h2>
<div className="bg-white rounded-lg shadow p-4 sm:p-6 mb-6 sm:mb-8">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Create New Post</h2>
<form onSubmit={editingPost ? handleEditPost : handleCreatePost} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Title</label>
@@ -890,7 +893,7 @@ export default function AdminPage() {
type="text"
value={newPost.title}
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
/>
</div>
@@ -900,7 +903,7 @@ export default function AdminPage() {
type="date"
value={newPost.date}
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
/>
</div>
@@ -910,7 +913,7 @@ export default function AdminPage() {
type="text"
value={newPost.tags}
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"
/>
</div>
@@ -919,41 +922,36 @@ export default function AdminPage() {
<textarea
value={newPost.summary}
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}
required
/>
</div>
{/* Labels Row */}
<div className="flex flex-row gap-4">
<div className="w-full md:w-1/2 flex items-end h-10">
<label className="block text-sm font-medium text-gray-700">Content (Markdown)</label>
</div>
<div className="w-full md:w-1/2 flex items-end h-10">
<label className="block text-sm font-medium text-gray-700">Live Preview</label>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 mt-1">
{/* Markdown Editor */}
<div className="w-full md:w-1/2 flex flex-col">
<textarea
value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono" style={{ height: '320px' }}
rows={10}
required
/>
</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 }} />
{/* Mobile-friendly content editor */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="w-full sm:w-1/2">
<label className="block text-sm font-medium text-gray-700 mb-2">Content (Markdown)</label>
<textarea
value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm sm:text-base"
style={{ height: '240px' }}
rows={10}
required
/>
</div>
<div className="w-full sm:w-1/2">
<label className="block text-sm font-medium text-gray-700 mb-2">Live Preview</label>
<div className="p-3 sm:p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '240px' }}>
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div>
</div>
</div>
</div>
<button
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'}
</button>
@@ -961,8 +959,8 @@ export default function AdminPage() {
</div>
{/* Content List */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">Content</h2>
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Content</h2>
<div className="space-y-4">
{/* Folders */}
{currentNodes
@@ -970,12 +968,12 @@ export default function AdminPage() {
.map((folder) => (
<div
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])}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📁</span>
<span className="font-semibold text-lg">{folder.name}</span>
<span className="text-xl sm:text-2xl">📁</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
</div>
</div>
))}
@@ -986,9 +984,9 @@ export default function AdminPage() {
const pinnedPosts = posts.filter(post => post.pinned);
const unpinnedPosts = posts.filter(post => !post.pinned);
return [...pinnedPosts, ...unpinnedPosts].map((post) => (
<div key={post.slug} className="border rounded-lg p-4 relative flex flex-col gap-2">
<div className="flex items-center gap-4">
<h3 className="text-xl font-semibold flex-1">{post.title}</h3>
<div key={post.slug} className="border rounded-lg p-3 sm:p-4 relative flex flex-col gap-2">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<h3 className="text-lg sm:text-xl font-semibold flex-1">{post.title}</h3>
<button
onClick={() => {
// 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('/');
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
</button>
{post.pinned && (
<span title="Angeheftet" className="text-2xl ml-2">📌</span>
<span title="Angeheftet" className="text-xl sm:text-2xl">📌</span>
)}
</div>
<p className="text-gray-600">{post.date}</p>
<p className="text-sm text-gray-500">{post.summary}</p>
<div className="mt-2 flex gap-2">
<p className="text-sm sm:text-base text-gray-600">{post.date}</p>
<p className="text-xs sm:text-sm text-gray-500">{post.summary}</p>
<div className="mt-2 flex flex-wrap gap-1 sm:gap-2">
{(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}
</span>
))}
@@ -1020,24 +1018,25 @@ export default function AdminPage() {
</div>
</div>
<div className="w-full mt-12">
{/* Mobile-friendly manage content section */}
<div className="w-full mt-8 sm:mt-12">
<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)}
aria-label={showManageContent ? 'Hide Manage Content' : 'Show Manage Content'}
>
<span>{showManageContent ? '' : ''}</span>
</button>
{showManageContent && (
<div className="mt-4 bg-white p-6 rounded-lg shadow text-center">
<p className="text-gray-600 mb-2">
<div className="mt-4 bg-white p-4 sm:p-6 rounded-lg shadow text-center">
<p className="text-gray-600 mb-2 text-sm sm:text-base">
Delete posts and folders, manage your content structure
</p>
{/* 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
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
</button>
@@ -1045,7 +1044,7 @@ export default function AdminPage() {
<button
key={idx}
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}
</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"
onClick={() => setManagePath([...managePath, folder.name])}
>
<span className="text-2xl">📁</span>
<span className="font-semibold text-lg">{folder.name}</span>
<span className="text-xl sm:text-2xl">📁</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
</div>
))}
</div>
@@ -1075,17 +1074,17 @@ export default function AdminPage() {
>
<div className="flex-1 text-left flex items-center gap-2">
{pinned.includes(post.slug) && (
<span title="Angeheftet" className="text-xl">📌</span>
<span title="Angeheftet" className="text-lg sm:text-xl">📌</span>
)}
<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-400">{post.summary}</div>
</div>
</div>
<button
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'}
>
{pinned.includes(post.slug) ? '' : ''}
@@ -1093,7 +1092,7 @@ export default function AdminPage() {
</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>

View File

@@ -12,7 +12,28 @@ import { getPostsDirectory } from '@/lib/postsDirectory';
const postsDirectory = getPostsDirectory();
// Function to get file creation date
function getFileCreationDate(filePath: string): Date {
const stats = fs.statSync(filePath);
return stats.birthtime ?? stats.mtime;
}
// Function to generate ID from text (matches frontend logic)
function generateId(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const renderer = new marked.Renderer();
// Custom heading renderer to add IDs
renderer.heading = (text, level) => {
const id = generateId(text);
return `<h${level} id="${id}">${text}</h${level}>`;
};
renderer.code = (code, infostring, escaped) => {
const lang = (infostring || '').match(/\S*/)?.[0];
const highlighted = lang && hljs.getLanguage(lang)
@@ -28,12 +49,6 @@ marked.setOptions({
renderer,
});
// Function to get file creation date
function getFileCreationDate(filePath: string): Date {
const stats = fs.statSync(filePath);
return stats.birthtime ?? stats.mtime;
}
async function getPostBySlug(slug: string) {
const realSlug = slug.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, `${realSlug}.md`);

View File

@@ -28,7 +28,22 @@ function getFileCreationDate(filePath: string): Date {
return stats.birthtime ?? stats.mtime;
}
// Function to generate ID from text (matches frontend logic)
function generateId(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const renderer = new marked.Renderer();
// Custom heading renderer to add IDs
renderer.heading = (text, level) => {
const id = generateId(text);
return `<h${level} id="${id}">${text}</h${level}>`;
};
renderer.code = (code, infostring, escaped) => {
const lang = (infostring || '').match(/\S*/)?.[0];
const highlighted = lang && hljs.getLanguage(lang)

View File

@@ -11,20 +11,142 @@
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Prevent horizontal scroll on mobile */
overflow-x: hidden;
}
/* Mobile-first responsive typography */
html {
font-size: 16px;
/* Improve mobile scrolling */
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 640px) {
html {
font-size: 16px; /* Keep 16px on mobile to prevent zoom */
}
}
/* Enhanced prose styles for mobile reading */
.prose {
max-width: none;
color: #374151;
}
.prose img {
margin: 2rem auto;
margin: 1.5rem auto;
border-radius: 0.5rem;
max-width: 100%;
height: auto;
}
.prose a {
color: #2563eb;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.prose a:hover {
color: #1d4ed8;
text-decoration-thickness: 2px;
}
/* Enhanced anchor link styles */
.prose a[href^="#"] {
color: #059669; /* Green color for anchor links */
font-weight: 500;
transition: all 0.2s ease;
}
.prose a[href^="#"]:hover {
color: #047857;
text-decoration-thickness: 2px;
background-color: #f0fdf4;
padding: 2px 4px;
border-radius: 4px;
}
/* Add subtle visual indicator for headings that have anchor links */
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
position: relative;
}
.prose h1:hover::before,
.prose h2:hover::before,
.prose h3:hover::before,
.prose h4:hover::before,
.prose h5:hover::before,
.prose h6:hover::before {
content: "🔗";
position: absolute;
left: -1.5rem;
opacity: 0.6;
font-size: 0.8em;
cursor: pointer;
}
/* Ensure proper spacing for anchor link indicators */
@media (min-width: 641px) {
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
padding-left: 0.5rem;
}
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: #111827;
font-weight: 700;
line-height: 1.25;
margin-top: 2rem;
margin-bottom: 1rem;
}
.prose h1 {
font-size: 1.875rem;
margin-top: 0;
}
.prose h2 {
font-size: 1.5rem;
}
.prose h3 {
font-size: 1.25rem;
}
.prose p {
margin-bottom: 1.25rem;
line-height: 1.75;
}
.prose ul, .prose ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose li {
margin-bottom: 0.5rem;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1.5rem 0;
font-style: italic;
color: #6b7280;
}
.prose code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
color: #dc2626;
}
/* Ensure highlight.js styles override Tailwind prose for code blocks */
@@ -33,12 +155,292 @@ body {
color: inherit !important;
padding: 0;
font-size: inherit;
/* Remove Tailwind's border radius if you want the highlight.js look */
border-radius: 0;
}
.prose pre {
background: #292d3e !important;
color: #fff !important;
border-radius: 0.5em;
border-radius: 0.5rem;
overflow-x: auto;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.875rem;
line-height: 1.5;
}
/* Mobile-specific prose adjustments for optimal reading */
@media (max-width: 640px) {
.prose {
font-size: 1rem; /* Larger base font for mobile reading */
line-height: 1.7; /* Better line spacing for mobile */
}
.prose h1 {
font-size: 1.75rem;
line-height: 1.3;
scroll-margin-top: 100px; /* Account for sticky header */
}
.prose h2 {
font-size: 1.5rem;
line-height: 1.3;
scroll-margin-top: 100px;
}
.prose h3 {
font-size: 1.25rem;
line-height: 1.3;
scroll-margin-top: 100px;
}
.prose h4, .prose h5, .prose h6 {
scroll-margin-top: 100px;
}
.prose p {
font-size: 1rem;
line-height: 1.7;
margin-bottom: 1.5rem;
}
.prose pre {
font-size: 0.875rem;
padding: 0.75rem;
margin: 1rem 0;
}
.prose img {
margin: 1.5rem auto;
}
/* Better mobile paragraph spacing */
.prose p + p {
margin-top: 1.5rem;
}
/* Improved mobile list spacing */
.prose ul, .prose ol {
margin: 1.5rem 0;
padding-left: 1.25rem;
}
.prose li {
margin-bottom: 0.75rem;
line-height: 1.6;
}
/* Better mobile blockquote */
.prose blockquote {
margin: 1.5rem 0;
padding: 1rem 0 1rem 1rem;
font-size: 1rem;
line-height: 1.6;
}
}
/* Desktop prose optimizations */
@media (min-width: 641px) {
.prose {
font-size: 1.125rem;
line-height: 1.8;
}
.prose h1 {
font-size: 2.25rem;
line-height: 1.2;
scroll-margin-top: 120px; /* Account for sticky header */
}
.prose h2 {
font-size: 1.875rem;
line-height: 1.3;
scroll-margin-top: 120px;
}
.prose h3 {
font-size: 1.5rem;
line-height: 1.4;
scroll-margin-top: 120px;
}
.prose h4, .prose h5, .prose h6 {
scroll-margin-top: 120px;
}
.prose p {
font-size: 1.125rem;
line-height: 1.8;
margin-bottom: 1.5rem;
}
}
/* Touch-friendly focus states and buttons */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Mobile touch targets - minimum 44px for accessibility */
@media (max-width: 640px) {
button,
a[role="button"],
input[type="button"],
input[type="submit"],
input[type="reset"] {
min-height: 44px;
min-width: 44px;
}
/* Improve touch scrolling */
.overflow-auto,
.overflow-scroll {
-webkit-overflow-scrolling: touch;
}
}
/* Mobile-friendly table styles */
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.875rem;
}
.prose th,
.prose td {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
.prose th {
background-color: #f9fafb;
font-weight: 600;
}
@media (max-width: 640px) {
.prose table {
font-size: 0.875rem;
}
.prose th,
.prose td {
padding: 0.5rem 0.25rem;
}
/* Make tables scrollable on mobile */
.prose table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
/* Mobile-specific animations */
@media (max-width: 640px) {
/* Reduce motion for mobile performance */
@media (prefers-reduced-motion: no-preference) {
.animate-spin {
animation-duration: 1.5s;
}
.animate-spin-reverse {
animation-duration: 1.5s;
}
}
}
/* Mobile-specific container adjustments */
@media (max-width: 640px) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
/* Mobile-specific form improvements */
@media (max-width: 640px) {
input,
textarea,
select {
font-size: 16px !important; /* Prevents zoom on iOS */
}
/* Improve mobile form spacing */
form > * + * {
margin-top: 1rem;
}
}
/* Mobile-specific modal improvements */
@media (max-width: 640px) {
.fixed.inset-0 {
padding: 1rem;
}
/* Ensure modals are properly sized on mobile */
.fixed.inset-0 > div {
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
}
/* Mobile-specific grid improvements */
@media (max-width: 640px) {
.grid {
gap: 0.75rem;
}
/* Ensure cards don't overflow on mobile */
.bg-white {
word-break: break-word;
}
}
/* Mobile-specific badge improvements */
@media (max-width: 640px) {
img[src*="shields.io"] {
max-width: 100%;
height: auto;
}
}
/* Mobile reading optimizations */
@media (max-width: 640px) {
/* Better text rendering for mobile */
.prose {
text-rendering: optimizeLegibility;
-webkit-font-feature-settings: "kern" 1;
font-feature-settings: "kern" 1;
}
/* Improved mobile paragraph readability */
.prose p {
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
}
/* Better mobile heading spacing */
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
margin-top: 2.5rem;
margin-bottom: 1.25rem;
}
/* Mobile-specific code block improvements */
.prose pre {
border-radius: 0.375rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Mobile-specific link improvements */
.prose a {
word-break: break-word;
}
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Link from 'next/link';
@@ -6,6 +6,7 @@ import Head from 'next/head';
import AboutButton from './AboutButton';
import BadgeButton from './BadgeButton';
import HeaderButtons from './HeaderButtons';
import MobileNav from './MobileNav';
const inter = Inter({ subsets: ['latin'] });
@@ -16,6 +17,12 @@ export const metadata: Metadata = {
description: `Ein Blog von ${blogOwner}, gebaut mit Next.js und Markdown`,
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
};
const PersonIcon = (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="6" r="4" stroke="white" strokeWidth="2" />
@@ -45,47 +52,96 @@ export default function RootLayout({
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
</Head>
<body className="h-full min-h-screen flex flex-col">
<body className={`${inter.className} h-full min-h-screen flex flex-col`}>
<MobileNav blogOwner={blogOwner} />
<div className="flex-1 flex flex-col">
<header className="bg-gray-100 p-4">
<div className="container mx-auto flex flex-col md:flex-row justify-between items-center">
<div className="flex gap-2 justify-center md:justify-start w-full md:w-auto mb-2 md:mb-0">
<img src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white" alt="Markdown" />
<img src="https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white" alt="Next.js" />
<img src="https://img.shields.io/badge/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="Tailwind CSS" />
<img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
</div>
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
<HeaderButtons />
<header className="bg-gray-100 p-3 sm:p-4 shadow-sm">
<div className="container mx-auto">
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
<div className="flex flex-wrap gap-1 sm:gap-2 justify-center sm:justify-start">
<img
src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white"
alt="Markdown"
className="h-6 sm:h-8"
/>
<img
src="https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white"
alt="Next.js"
className="h-6 sm:h-8"
/>
<img
src="https://img.shields.io/badge/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>
</header>
{children}
</div>
<footer className="bg-gray-100 p-2 mt-auto">
<div className="container mx-auto flex flex-col items-center md:flex-row md:justify-between gap-2">
<div className="text-center w-full md:w-auto">
<span className="text-gray-500" style={{ fontSize: '12px' }}>
&copy; {new Date().getFullYear()} {blogOwner}
</span>
</div>
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0">
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
<a href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
<img src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white" alt="GitHub" />
</a>
)}
{process.env.NEXT_SOCIAL_INSTAGRAM && (
<a href={process.env.NEXT_SOCIAL_INSTAGRAM.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
<img src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white" alt="Instagram" />
</a>
)}
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && (
<a href={process.env.NEXT_SOCIAL_TWITTER_LINK.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer">
<img src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white" alt="Twitter" />
</a>
)}
<footer className="bg-gray-100 p-3 sm:p-4 mt-auto shadow-inner">
<div className="container mx-auto">
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
<div className="text-center sm:text-left">
<span className="text-gray-500 text-sm">
&copy; {new Date().getFullYear()} {blogOwner}
</span>
</div>
<div className="hidden sm:flex flex-wrap gap-2 justify-center sm:justify-end">
{process.env.NEXT_SOCIAL_GITHUB_STATE === "true" && process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE && (
<a
href={process.env.NEXT_SOCIAL_GITHUB_LINK_IF_TRUE.replace(/(^\"|\"$)/g, '')}
target="_blank"
rel="noopener noreferrer"
className="h-6 sm:h-8"
>
<img
src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white"
alt="GitHub"
className="h-6 sm:h-8"
/>
</a>
)}
{process.env.NEXT_SOCIAL_INSTAGRAM && (
<a
href={process.env.NEXT_SOCIAL_INSTAGRAM.replace(/(^\"|\"$)/g, '')}
target="_blank"
rel="noopener noreferrer"
className="h-6 sm:h-8"
>
<img
src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white"
alt="Instagram"
className="h-6 sm:h-8"
/>
</a>
)}
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && (
<a
href={process.env.NEXT_SOCIAL_TWITTER_LINK.replace(/(^\"|\"$)/g, '')}
target="_blank"
rel="noopener noreferrer"
className="h-6 sm:h-8"
>
<img
src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white"
alt="Twitter"
className="h-6 sm:h-8"
/>
</a>
)}
</div>
</div>
</div>
</footer>

View File

@@ -102,26 +102,30 @@ export default function Home() {
}
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center justify-between">
<h1 className="text-4xl font-bold mb-2 md:mb-0">{blogOwner}&apos;s Blog</h1>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Suche nach Titel, Tag oder Text..."
className="border rounded px-4 py-2 w-full md:w-80"
/>
<main className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
{/* Mobile-first header section */}
<div className="mb-6 sm:mb-8 space-y-4 sm:space-y-0 sm:flex sm:flex-row sm:gap-4 sm:items-center sm:justify-between">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}&apos;s Blog</h1>
<div className="w-full sm:w-auto">
<input
type="text"
value={search}
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>
{/* Search Results Section */}
{search.trim() && (
<div className="mb-10">
<h2 className="text-2xl font-bold mb-4">Suchergebnisse</h2>
<div className="grid gap-8">
<div className="mb-8 sm:mb-10">
<h2 className="text-xl sm:text-2xl font-bold mb-4">Suchergebnisse</h2>
<div className="grid gap-4 sm:gap-8">
{(() => {
const posts = filterPosts(collectPosts(tree));
if (posts.length === 0) {
return <div className="text-gray-500">Keine Beiträge gefunden.</div>;
return <div className="text-gray-500 text-center py-8">Keine Beiträge gefunden.</div>;
}
return posts.map((post: any) => {
// Determine folder path from slug
@@ -130,38 +134,38 @@ export default function Home() {
folderPath = post.slug.split('/').slice(0, -1).join('/');
}
return (
<article key={post.slug} className="border rounded-lg p-6 hover:shadow-lg transition-shadow relative">
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
{post.pinned && (
<span className="absolute top-4 right-4 text-2xl" title="Pinned">📌</span>
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
)}
<Link href={`/posts/${post.slug}`}>
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<Link href={`/posts/${post.slug}`} className="block">
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
{folderPath && (
<div className="text-xs text-gray-400 mb-1">in <span className="font-mono">{folderPath}</span></div>
<div className="text-xs text-gray-400 mb-2">in <span className="font-mono">{folderPath}</span></div>
)}
<div className="text-gray-600 mb-4">
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
{post.date ? (
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
) : (
<div className="flex flex-col items-center">
<div className="flex">
<span className="text-2xl animate-spin mr-2"></span>
<span className="text-2xl animate-spin-reverse"></span>
<span className="text-xl sm:text-2xl animate-spin mr-2"></span>
<span className="text-xl sm:text-2xl animate-spin-reverse"></span>
</div>
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
</div>
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<p className="text-gray-700 mb-4">{post.summary}</p>
<div className="flex gap-2">
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
<div className="flex flex-wrap gap-1 sm:gap-2">
{post.tags.map((tag: string) => {
const q = search.trim().toLowerCase();
const isMatch = q && tag.toLowerCase().includes(q);
return (
<span
key={tag}
className={`bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm ${isMatch ? 'bg-yellow-200 font-bold' : ''}`}
className={`bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm ${isMatch ? 'bg-yellow-200 font-bold' : ''}`}
>
{tag}
</span>
@@ -176,66 +180,72 @@ export default function Home() {
</div>
</div>
)}
{/* Normal Content (folders and posts) only if not searching */}
{!search.trim() && (
<>
<nav className="mb-6 text-sm text-gray-600 flex gap-2 items-center">
{breadcrumbs.map((bc, idx) => (
<span key={bc.name} className="flex items-center">
{idx > 0 && <span className="mx-1">/</span>}
<button
className="hover:underline"
onClick={() => setCurrentPath(bc.path)}
disabled={idx === breadcrumbs.length - 1}
>
{bc.name}
</button>
</span>
))}
{/* Mobile-friendly breadcrumbs */}
<nav className="mb-4 sm:mb-6 text-sm text-gray-600">
<div className="flex flex-wrap gap-1 sm:gap-2 items-center">
{breadcrumbs.map((bc, idx) => (
<span key={bc.name} className="flex items-center">
{idx > 0 && <span className="mx-1 text-gray-400">/</span>}
<button
className="hover:underline px-1 py-1 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={() => setCurrentPath(bc.path)}
disabled={idx === breadcrumbs.length - 1}
>
{bc.name}
</button>
</span>
))}
</div>
</nav>
<div className="grid gap-8">
<div className="grid gap-4 sm:gap-8">
{/* Folders */}
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
<div
key={folder.path}
className="border rounded-lg p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition"
className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setCurrentPath([...currentPath, folder.name])}
>
<span className="font-semibold text-lg">📁 {folder.name}</span>
<span className="font-semibold text-base sm:text-lg">📁 {folder.name}</span>
</div>
))}
{/* Posts */}
{(() => {
const posts = nodes.filter((n) => n.type === 'post');
const pinnedPosts = posts.filter((post: any) => post.pinned);
const unpinnedPosts = posts.filter((post: any) => !post.pinned);
return [...pinnedPosts, ...unpinnedPosts].map((post: any) => (
<article key={post.slug} className="border rounded-lg p-6 hover:shadow-lg transition-shadow relative">
<article key={post.slug} className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 hover:shadow-lg transition-shadow relative sm:bg-white">
{post.pinned && (
<span className="absolute top-4 right-4 text-2xl" title="Pinned">📌</span>
<span className="absolute top-3 right-3 sm:top-4 sm:right-4 text-xl sm:text-2xl" title="Pinned">📌</span>
)}
<Link href={`/posts/${post.slug}`}>
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<div className="text-gray-600 mb-4">
<Link href={`/posts/${post.slug}`} className="block">
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 pr-8 sm:pr-12">{post.title}</h2>
<div className="text-sm sm:text-base text-gray-600 mb-3 sm:mb-4">
{post.date ? (
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
) : (
<div className="flex flex-col items-center">
<div className="flex">
<span className="text-2xl animate-spin mr-2"></span>
<span className="text-2xl animate-spin-reverse"></span>
<span className="text-xl sm:text-2xl animate-spin mr-2"></span>
<span className="text-xl sm:text-2xl animate-spin-reverse"></span>
</div>
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
<div className="text-lg sm:text-xl font-bold mt-2">In Bearbeitung</div>
</div>
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<p className="text-gray-700 mb-4">{post.summary}</p>
<div className="flex gap-2">
<p className="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">{post.summary}</p>
<div className="flex flex-wrap gap-1 sm:gap-2">
{post.tags.map((tag: string) => (
<span
key={tag}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
className="bg-gray-100 text-gray-800 px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm"
>
{tag}
</span>

View File

@@ -31,68 +31,465 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
return () => clearInterval(interval);
}, [slugPath]);
// On post load or update, scroll to anchor in hash if present
// Enhanced anchor scrolling logic
useEffect(() => {
// Scroll to anchor if hash is present
const scrollToHash = () => {
if (window.location.hash) {
const id = window.location.hash.substring(1);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
// On initial load
scrollToHash();
// Listen for hash changes
window.addEventListener('hashchange', scrollToHash);
return () => {
window.removeEventListener('hashchange', scrollToHash);
};
}, [post]);
if (!post) return;
// Intercept anchor clicks in rendered markdown to ensure smooth scrolling to headings
useEffect(() => {
// Find the rendered markdown container
const prose = document.querySelector('.prose');
if (!prose) return;
/**
* Handles clicks on anchor links (e.g. Table of Contents links) inside the markdown.
* - If the link is an in-page anchor (starts with #), prevent default navigation.
* - Try to find an element with the corresponding id and scroll to it.
* - If not found, search all headings for one whose text matches the anchor (case-insensitive, ignoring spaces/punctuation).
* - If a match is found, scroll to that heading.
* - Update the URL hash without reloading the page.
*/
const handleClick = (e: Event) => {
if (!(e instanceof MouseEvent)) return;
let target = e.target as HTMLElement | null;
// Traverse up to find the closest anchor tag
while (target && target.tagName !== 'A') {
target = target.parentElement;
// Function to scroll to element using scrollIntoView
const scrollToElement = (element: HTMLElement) => {
// Get comprehensive element information
const documentHeight = document.documentElement.scrollHeight;
const windowHeight = window.innerHeight;
// Detect if we're on desktop or mobile layout
const isDesktop = window.innerWidth >= 640; // sm breakpoint
const proseContainer = document.querySelector('.prose');
console.log('Layout detection:', {
isDesktop,
windowWidth: window.innerWidth,
proseContainer: proseContainer ? 'found' : 'not found'
});
// Get the absolute position of the element
const currentScrollY = window.scrollY;
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();
const id = target.getAttribute('href')!.slice(1);
let el = document.getElementById(id);
if (!el) {
// Try to find a heading whose text matches the id (case-insensitive, ignoring spaces/punctuation)
const headings = prose.querySelectorAll('h1, h2, h3, h4, h5, h6');
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]+/g, '');
const normId = normalize(id);
const found = Array.from(headings).find(h => normalize(h.textContent || '') === normId);
el = (found as HTMLElement) || null;
// If we're not at the top, temporarily scroll to top to get accurate positions
if (currentScrollY > 0 && elementTop === currentScrollY) {
// Temporarily scroll to top to get accurate element positions
window.scrollTo(0, 0);
// Wait a moment for the scroll to complete, then measure
setTimeout(() => {
const rect = element.getBoundingClientRect();
elementTop = rect.top;
// 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) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', `#${id}`);
performActualScroll(elementTop);
}
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 () => {
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]);
@@ -111,39 +508,106 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
}
return (
<article className="min-h-screen px-4 py-10 mx-auto md:mx-16 rounded-2xl shadow-lg">
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
Zurück zu den Beiträgen
</Link>
<h1 className="text-4xl font-bold mb-4 text-left">{post.title}</h1>
<div className="text-gray-600 mb-8 text-left">
{post.date ? (
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
) : (
<div className="flex flex-col items-start">
<div className="flex">
<span className="text-2xl animate-spin mr-2"></span>
<span className="text-2xl animate-spin-reverse"></span>
</div>
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
</div>
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<div className="flex gap-2 mb-8">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
<article className="min-h-screen">
{/* Mobile: Full width, no borders */}
<div className="sm:hidden">
{/* Mobile back button */}
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 px-4 py-3">
<Link
href="/"
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm font-medium"
>
{tag}
</span>
))}
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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
className="prose prose-lg max-w-full text-left"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}

View File

@@ -28,7 +28,100 @@ function getFileCreationDate(filePath: string): Date {
return stats.birthtime;
}
// Function to generate ID from text (matches frontend logic)
function generateId(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Enhanced slugification function that matches GitHub-style anchor links
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
// Function to process anchor links in markdown content
function processAnchorLinks(content: string): string {
// Find all markdown links that point to anchors (e.g., [text](#anchor))
return content.replace(/\[([^\]]+)\]\(#([^)]+)\)/g, (match, linkText, anchor) => {
// Only slugify if the anchor doesn't already look like a slug
// This prevents double-processing of already-correct anchor links
const isAlreadySlugified = /^[a-z0-9-]+$/.test(anchor);
const slugifiedAnchor = isAlreadySlugified ? anchor : slugify(anchor);
return `[${linkText}](#${slugifiedAnchor})`;
});
}
// Utility function to debug anchor links (for development)
export function debugAnchorLinks(content: string): void {
if (process.env.NODE_ENV !== 'development') return;
console.log('=== Anchor Link Debug Info ===');
// Extract all headings and their IDs
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings: Array<{ level: number; text: string; id: string }> = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = slugify(text);
headings.push({ level, text, id });
}
console.log('Generated heading IDs:');
headings.forEach(({ level, text, id }) => {
console.log(` H${level}: "${text}" -> id="${id}"`);
});
// Extract all anchor links
const anchorLinkRegex = /\[([^\]]+)\]\(#([^)]+)\)/g;
const anchorLinks: Array<{ linkText: string; originalAnchor: string; slugifiedAnchor: string }> = [];
while ((match = anchorLinkRegex.exec(content)) !== null) {
const linkText = match[1];
const originalAnchor = match[2];
const slugifiedAnchor = slugify(originalAnchor);
anchorLinks.push({ linkText, originalAnchor, slugifiedAnchor });
}
console.log('Anchor links found:');
anchorLinks.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
const headingExists = headings.some(h => h.id === slugifiedAnchor);
const status = headingExists ? '✅' : '❌';
console.log(` ${status} [${linkText}](#${originalAnchor}) -> [${linkText}](#${slugifiedAnchor})`);
});
// Show missing headings
const missingAnchors = anchorLinks.filter(({ slugifiedAnchor }) =>
!headings.some(h => h.id === slugifiedAnchor)
);
if (missingAnchors.length > 0) {
console.warn('Missing headings for these anchor links:');
missingAnchors.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
console.warn(` - [${linkText}](#${originalAnchor}) -> id="${slugifiedAnchor}"`);
});
}
console.log('=== End Debug Info ===');
}
const renderer = new marked.Renderer();
// Custom heading renderer to add IDs
renderer.heading = (text, level) => {
const id = slugify(text);
return `<h${level} id="${id}">${text}</h${level}>`;
};
renderer.code = (code, infostring, escaped) => {
const lang = (infostring || '').match(/\S*/)?.[0];
const highlighted = lang && hljs.getLanguage(lang)
@@ -53,7 +146,12 @@ export async function getPostBySlug(slug: string): Promise<Post> {
let processedContent = '';
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 purify = DOMPurify(window);
processedContent = purify.sanitize(rawHtml as string, {

View File

@@ -6,7 +6,45 @@ module.exports = {
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
extend: {
screens: {
'xs': '475px',
'3xl': '1600px',
},
spacing: {
'18': '4.5rem',
'88': '22rem',
},
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
},
colors: {
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
animation: {
'spin-reverse': 'spin 1s linear infinite reverse',
},
},
},
plugins: [
require('@tailwindcss/typography'),