mobile ; heading scroll broken

This commit is contained in:
2025-06-21 20:39:22 +02:00
parent 7b556b2d09
commit 1cc864e4f0
12 changed files with 1117 additions and 325 deletions

View File

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

View File

@@ -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,31 +692,32 @@ 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>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-2">
<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>
<button <button
onClick={() => setShowChangePassword(true)} onClick={() => setShowChangePassword(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" className="px-4 py-3 sm:py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-base font-medium"
> >
Passwort ändern Passwort ändern
</button> </button>
{/* 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 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="px-4 py-3 sm:py-2 bg-green-600 text-white rounded hover:bg-green-700 text-base font-medium"
title="Export Docker Posts" title="Export Docker Posts"
> >
Export Posts Export Posts
@@ -726,7 +727,7 @@ export default function AdminPage() {
<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 +740,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 +757,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 +768,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 +779,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 +789,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 +798,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 +821,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 +841,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 +868,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 +876,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 +891,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 +901,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 +911,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 +920,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">
<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 <textarea
value={newPost.content} value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })} 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' }} 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} rows={10}
required required
/> />
</div> </div>
{/* Live Markdown Preview */} <div className="w-full sm:w-1/2">
<div className="w-full md:w-1/2 flex flex-col"> <label className="block text-sm font-medium text-gray-700 mb-2">Live Preview</label>
<div className="p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '320px' }}> <div className="p-3 sm:p-4 border rounded bg-gray-50 overflow-auto" style={{ height: '240px' }}>
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} /> <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div> </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 +957,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 +966,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 +982,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 +993,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 +1016,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 +1042,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 +1056,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 +1072,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 +1090,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>

View File

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

View File

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

View File

@@ -11,20 +11,101 @@
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;
}
.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 +114,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;
}
} }

View File

@@ -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,49 +52,98 @@ 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`}>
<MobileNav blogOwner={blogOwner} />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="bg-gray-100 p-4"> <header className="bg-gray-100 p-3 sm:p-4 shadow-sm">
<div className="container mx-auto flex flex-col md:flex-row justify-between items-center"> <div className="container mx-auto">
<div className="flex gap-2 justify-center md:justify-start w-full md:w-auto mb-2 md:mb-0"> <div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
<img src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white" alt="Markdown" /> <div className="flex flex-wrap gap-1 sm:gap-2 justify-center sm:justify-start">
<img src="https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white" alt="Next.js" /> <img
<img src="https://img.shields.io/badge/tailwindcss-%2338BDF8.svg?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="Tailwind CSS" /> src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white"
<img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /> 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>
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0"> <div className="hidden sm:flex gap-2 justify-center sm:justify-end">
<HeaderButtons /> <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">
<span className="text-gray-500 text-sm">
&copy; {new Date().getFullYear()} {blogOwner} &copy; {new Date().getFullYear()} {blogOwner}
</span> </span>
</div> </div>
<div className="flex gap-2 justify-center w-full md:w-auto mt-2 md:mt-0"> <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 && ( {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"> <a
<img src="https://img.shields.io/badge/GitHub-%23121011.svg?style=for-the-badge&logo=GitHub&logoColor=white" alt="GitHub" /> 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> </a>
)} )}
{process.env.NEXT_SOCIAL_INSTAGRAM && ( {process.env.NEXT_SOCIAL_INSTAGRAM && (
<a href={process.env.NEXT_SOCIAL_INSTAGRAM.replace(/(^\"|\"$)/g, '')} target="_blank" rel="noopener noreferrer"> <a
<img src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white" alt="Instagram" /> 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> </a>
)} )}
{process.env.NEXT_SOCIAL_TWITTER === "true" && process.env.NEXT_SOCIAL_TWITTER_LINK && ( {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"> <a
<img src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white" alt="Twitter" /> 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> </a>
)} )}
</div> </div>
</div> </div>
</div>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -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}&apos;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">
<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 <input
type="text" type="text"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Suche nach Titel, Tag oder Text..." placeholder="Suche nach Titel, Tag oder Text..."
className="border rounded px-4 py-2 w-full md:w-80" 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,15 +180,18 @@ 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 */}
<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) => ( {breadcrumbs.map((bc, idx) => (
<span key={bc.name} className="flex items-center"> <span key={bc.name} className="flex items-center">
{idx > 0 && <span className="mx-1">/</span>} {idx > 0 && <span className="mx-1 text-gray-400">/</span>}
<button <button
className="hover:underline" className="hover:underline px-1 py-1 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={() => setCurrentPath(bc.path)} onClick={() => setCurrentPath(bc.path)}
disabled={idx === breadcrumbs.length - 1} disabled={idx === breadcrumbs.length - 1}
> >
@@ -192,50 +199,53 @@ export default function Home() {
</button> </button>
</span> </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>

View File

@@ -31,68 +31,180 @@ 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 generate ID from text (matches markdown parser behavior)
useEffect(() => { const generateId = (text: string): string => {
// Find the rendered markdown container return text
const prose = document.querySelector('.prose'); .toLowerCase()
if (!prose) return; .replace(/[^a-z0-9]+/g, '-')
/** .replace(/^-+|-+$/g, '');
* 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. // Function to scroll to element
* - If not found, search all headings for one whose text matches the anchor (case-insensitive, ignoring spaces/punctuation). const scrollToElement = (element: HTMLElement) => {
* - If a match is found, scroll to that heading. console.log('Attempting to scroll to element:', element.textContent);
* - Update the URL hash without reloading the page.
*/ let attempts = 0;
const handleClick = (e: Event) => { const maxAttempts = 20; // 1 second max wait time
if (!(e instanceof MouseEvent)) return;
let target = e.target as HTMLElement | null; // Wait for the element to be properly positioned in the DOM
// Traverse up to find the closest anchor tag const waitForElementPosition = () => {
while (target && target.tagName !== 'A') { attempts++;
target = target.parentElement; const rect = element.getBoundingClientRect();
console.log('Element rect (attempt', attempts, '):', rect);
// If the element has no dimensions, wait a bit more
if ((rect.height === 0 && rect.width === 0) && attempts < maxAttempts) {
console.log('Element not positioned yet, waiting... (attempt', attempts, ')');
setTimeout(waitForElementPosition, 50);
return;
} }
if (target && target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
e.preventDefault(); // If we've tried too many times, use fallback method
const id = target.getAttribute('href')!.slice(1); if (attempts >= maxAttempts) {
let el = document.getElementById(id); console.log('Max attempts reached, using fallback scroll method');
if (!el) { element.scrollIntoView({
// Try to find a heading whose text matches the id (case-insensitive, ignoring spaces/punctuation) behavior: 'smooth',
const headings = prose.querySelectorAll('h1, h2, h3, h4, h5, h6'); block: 'start'
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); // Apply offset after scroll
el = (found as HTMLElement) || null; setTimeout(() => {
const isDesktop = window.innerWidth >= 640;
const scrollOffset = isDesktop ? 120 : 100;
window.scrollBy({
top: -scrollOffset,
behavior: 'smooth'
});
}, 100);
return;
} }
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' }); console.log('Element offsetTop:', element.offsetTop);
history.replaceState(null, '', `#${id}`); console.log('Current scroll position:', window.scrollY);
const isDesktop = window.innerWidth >= 640;
const scrollOffset = isDesktop ? 120 : 100;
// Use offsetTop which is more reliable for positioned elements
const elementTop = element.offsetTop - scrollOffset;
console.log('Target scroll position:', elementTop);
console.log('Scroll offset used:', scrollOffset);
// Perform the scroll
window.scrollTo({
top: elementTop,
behavior: 'smooth'
});
console.log('Scroll command executed');
};
// Start the positioning check
waitForElementPosition();
};
// Function to find element by ID or generated ID
const findElement = (id: string): HTMLElement | null => {
console.log('Looking for element with ID:', id);
// Try direct ID match first
let element = document.getElementById(id);
if (element) {
console.log('Found element by direct ID:', element.textContent);
return element;
} }
// Try to find by generated ID from all headings
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
console.log('Found', headings.length, 'headings on page');
const found = Array.from(headings).find(heading => {
const headingId = generateId(heading.textContent || '');
console.log('Checking heading:', heading.textContent, '-> ID:', headingId, 'vs target:', id);
return headingId === id;
});
if (found) {
console.log('Found element by generated ID:', found.textContent);
return found as HTMLElement;
}
console.log('Element not found for ID:', id);
return null;
};
// Function to handle hash-based scrolling
const handleHashScroll = () => {
if (!window.location.hash) return;
const id = window.location.hash.substring(1);
console.log('Handling hash scroll for:', id);
const element = findElement(id);
if (element) {
console.log('Found element for hash scroll:', element.textContent);
setTimeout(() => scrollToElement(element), 100);
} else {
console.log('Element not found for hash:', id);
} }
}; };
prose.addEventListener('click', handleClick);
// 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;
event.preventDefault();
const href = link.getAttribute('href')!;
const id = href.substring(1);
console.log('Anchor click detected:', id);
const element = findElement(id);
if (element) {
console.log('Found element for anchor click:', element.textContent);
scrollToElement(element);
// Update URL without reload
history.replaceState(null, '', href);
} else {
console.log('Element not found for anchor:', id);
}
};
// Add IDs to headings that don't have them
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
console.log('Processing', headings.length, 'headings for ID assignment');
headings.forEach(heading => {
if (!heading.id) {
const id = generateId(heading.textContent || '');
heading.id = id;
console.log('Added ID to heading:', heading.textContent, '->', id);
} else {
console.log('Heading already has ID:', heading.textContent, '->', heading.id);
}
});
// Handle initial hash scroll
setTimeout(handleHashScroll, 100);
// Add event listeners
document.addEventListener('click', handleAnchorClick);
window.addEventListener('hashchange', handleHashScroll);
return () => { return () => {
prose.removeEventListener('click', handleClick); document.removeEventListener('click', handleAnchorClick);
window.removeEventListener('hashchange', handleHashScroll);
}; };
}, [post]); }, [post]);
@@ -111,26 +223,39 @@ 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">
{/* 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"
>
<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> </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>
<div className="text-xl font-bold mt-2">In Bearbeitung</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>
)} )}
<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>
<div className="flex gap-2 mb-8">
<div className="flex flex-wrap gap-2 mb-6">
{post.tags.map((tag) => ( {post.tags.map((tag) => (
<span <span
key={tag} key={tag}
@@ -140,10 +265,64 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
</span> </span>
))} ))}
</div> </div>
{/* Mobile-optimized prose content */}
<div <div
className="prose prose-lg max-w-full text-left" className="prose prose-sm max-w-none prose-headings:scroll-mt-16 prose-p:leading-relaxed prose-p:text-base"
dangerouslySetInnerHTML={{ __html: post.content }} 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>
</article> </article>
); );
} }

View File

@@ -28,7 +28,22 @@ 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, '');
}
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)

View File

@@ -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'),