mobile ; heading scroll broken
This commit is contained in:
@@ -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
93
src/app/MobileNav.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface MobileNavProps {
|
||||
blogOwner: string;
|
||||
}
|
||||
|
||||
export default function MobileNav({ blogOwner }: MobileNavProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="fixed top-4 right-4 z-50 p-2 bg-white rounded-lg shadow-lg border border-gray-200"
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={toggleMenu}>
|
||||
<div className="fixed top-0 right-0 w-64 h-full bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-6">{blogOwner}'s Blog</h2>
|
||||
|
||||
<nav className="space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
🏠 Home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin"
|
||||
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
🔐 Admin
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={typeof window !== 'undefined' ? window.location.origin.replace('3000', '80') : '#'}
|
||||
className="block py-2 px-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
👤 About Me
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-500">
|
||||
© {new Date().getFullYear()} {blogOwner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -219,37 +219,38 @@ export default function ManagePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
@@ -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,31 +692,32 @@ 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">
|
||||
{/* 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 sm:flex-row gap-2 sm:gap-2">
|
||||
<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>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
{/* 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">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Export Posts
|
||||
@@ -726,7 +727,7 @@ export default function AdminPage() {
|
||||
<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 +740,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 +757,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 +768,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 +779,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 +789,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 +798,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 +821,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 +841,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 +868,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 +876,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 +891,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 +901,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 +911,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 +920,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">
|
||||
{/* 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" 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}
|
||||
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="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 +957,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 +966,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 +982,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 +993,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 +1016,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 +1042,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 +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"
|
||||
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 +1072,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 +1090,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>
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,20 +11,101 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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 +114,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;
|
||||
}
|
||||
}
|
||||
@@ -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,49 +52,98 @@ 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" />
|
||||
<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="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 />
|
||||
</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' }}>
|
||||
<footer className="bg-gray-100 p-3 sm:p-4 mt-auto shadow-inner">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex flex-col space-y-3 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="text-center sm:text-left">
|
||||
<span className="text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} {blogOwner}
|
||||
</span>
|
||||
</div>
|
||||
<div className="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 && (
|
||||
<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
|
||||
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">
|
||||
<img src="https://img.shields.io/badge/Instagram-%23E4405F.svg?style=for-the-badge&logo=Instagram&logoColor=white" alt="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">
|
||||
<img src="https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white" alt="Twitter" />
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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}'s Blog</h1>
|
||||
<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}'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="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>
|
||||
|
||||
{/* 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,15 +180,18 @@ 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">
|
||||
{/* 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">/</span>}
|
||||
{idx > 0 && <span className="mx-1 text-gray-400">/</span>}
|
||||
<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)}
|
||||
disabled={idx === breadcrumbs.length - 1}
|
||||
>
|
||||
@@ -192,50 +199,53 @@ export default function Home() {
|
||||
</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>
|
||||
|
||||
@@ -31,68 +31,180 @@ 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 generate ID from text (matches markdown parser behavior)
|
||||
const generateId = (text: string): string => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
// Function to scroll to element
|
||||
const scrollToElement = (element: HTMLElement) => {
|
||||
console.log('Attempting to scroll to element:', element.textContent);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20; // 1 second max wait time
|
||||
|
||||
// Wait for the element to be properly positioned in the DOM
|
||||
const waitForElementPosition = () => {
|
||||
attempts++;
|
||||
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();
|
||||
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've tried too many times, use fallback method
|
||||
if (attempts >= maxAttempts) {
|
||||
console.log('Max attempts reached, using fallback scroll method');
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
|
||||
// Apply offset after scroll
|
||||
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' });
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
|
||||
console.log('Element offsetTop:', element.offsetTop);
|
||||
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 () => {
|
||||
prose.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('click', handleAnchorClick);
|
||||
window.removeEventListener('hashchange', handleHashScroll);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
@@ -111,26 +223,39 @@ 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
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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 gap-2 mb-8">
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
@@ -140,10 +265,64 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile-optimized prose content */}
|
||||
<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 }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,22 @@ 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, '');
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user