migrate to nextjs

This commit is contained in:
2026-01-29 18:50:43 +01:00
parent 019b59602f
commit 4906fc0b7b
43 changed files with 2795 additions and 3515 deletions

View File

@@ -0,0 +1,268 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileExampleManager } from '../../../src/data/fileExamples';
// Simple ZIP creation without external dependencies
class SimpleZipCreator {
private files: Array<{ filename: string; content: string }> = [];
addFile(filename: string, content: string) {
this.files.push({ filename, content });
}
// Create a basic ZIP file structure
create(): number[] {
const encoder = new TextEncoder();
const chunks: number[][] = [];
let offset = 0;
const centralDirectory: Array<{
name: string;
offset: number;
size: number;
compressedSize: number;
}> = [];
// Process each file
for (const file of this.files) {
const contentBytes = Array.from(encoder.encode(file.content));
const filenameBytes = Array.from(encoder.encode(file.filename));
// Local file header
const localHeader: number[] = [];
// Local file header signature (little endian)
localHeader.push(0x50, 0x4b, 0x03, 0x04);
// Version needed to extract
localHeader.push(20, 0);
// General purpose bit flag
localHeader.push(0, 0);
// Compression method (0 = store)
localHeader.push(0, 0);
// Last modified time/date
localHeader.push(0, 0, 0, 0);
// CRC32 (0 for simplicity)
localHeader.push(0, 0, 0, 0);
// Compressed size
localHeader.push(...intToLittleEndian(contentBytes.length, 4));
// Uncompressed size
localHeader.push(...intToLittleEndian(contentBytes.length, 4));
// Filename length
localHeader.push(...intToLittleEndian(filenameBytes.length, 2));
// Extra field length
localHeader.push(0, 0);
// Add filename
localHeader.push(...filenameBytes);
chunks.push(localHeader);
chunks.push(contentBytes);
// Store info for central directory
centralDirectory.push({
name: file.filename,
offset: offset,
size: contentBytes.length,
compressedSize: contentBytes.length
});
offset += localHeader.length + contentBytes.length;
}
// Central directory
const centralDirectoryChunks: number[][] = [];
let centralDirectoryOffset = offset;
for (const entry of centralDirectory) {
const filenameBytes = Array.from(encoder.encode(entry.name));
const centralHeader: number[] = [];
// Central directory header signature
centralHeader.push(0x50, 0x4b, 0x01, 0x02);
// Version made by
centralHeader.push(20, 0);
// Version needed to extract
centralHeader.push(20, 0);
// General purpose bit flag
centralHeader.push(0, 0);
// Compression method
centralHeader.push(0, 0);
// Last modified time/date
centralHeader.push(0, 0, 0, 0);
// CRC32
centralHeader.push(0, 0, 0, 0);
// Compressed size
centralHeader.push(...intToLittleEndian(entry.compressedSize, 4));
// Uncompressed size
centralHeader.push(...intToLittleEndian(entry.size, 4));
// Filename length
centralHeader.push(...intToLittleEndian(filenameBytes.length, 2));
// Extra field length
centralHeader.push(0, 0);
// File comment length
centralHeader.push(0, 0);
// Disk number start
centralHeader.push(0, 0);
// Internal file attributes
centralHeader.push(0, 0);
// External file attributes
centralHeader.push(0, 0, 0, 0);
// Relative offset of local header
centralHeader.push(...intToLittleEndian(entry.offset, 4));
// Add filename
centralHeader.push(...filenameBytes);
centralDirectoryChunks.push(centralHeader);
}
const centralDirectorySize = centralDirectoryChunks.reduce((sum, chunk) => sum + chunk.length, 0);
// End of central directory
const endOfCentralDirectory: number[] = [];
// End of central directory signature
endOfCentralDirectory.push(0x50, 0x4b, 0x05, 0x06);
// Number of this disk
endOfCentralDirectory.push(0, 0);
// Number of the disk with the start of the central directory
endOfCentralDirectory.push(0, 0);
// Total number of entries on this disk
endOfCentralDirectory.push(...intToLittleEndian(centralDirectory.length, 2));
// Total number of entries
endOfCentralDirectory.push(...intToLittleEndian(centralDirectory.length, 2));
// Size of the central directory
endOfCentralDirectory.push(...intToLittleEndian(centralDirectorySize, 4));
// Offset of start of central directory
endOfCentralDirectory.push(...intToLittleEndian(centralDirectoryOffset, 4));
// ZIP file comment length
endOfCentralDirectory.push(0, 0);
// Combine all chunks
const result: number[] = [];
chunks.forEach(chunk => result.push(...chunk));
centralDirectoryChunks.forEach(chunk => result.push(...chunk));
result.push(...endOfCentralDirectory);
return result;
}
}
// Helper function to convert integer to little endian bytes
function intToLittleEndian(value: number, bytes: number): number[] {
const result: number[] = [];
for (let i = 0; i < bytes; i++) {
result.push((value >> (i * 8)) & 0xff);
}
return result;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fileIds } = body;
if (!Array.isArray(fileIds) || fileIds.length === 0) {
return NextResponse.json(
{ error: 'fileIds array is required and must not be empty' },
{ status: 400 }
);
}
// Get file contents
const files = await Promise.all(
fileIds.map(async (id) => {
const file = await FileExampleManager.getFileExample(id);
if (!file) {
throw new Error(`File with id ${id} not found`);
}
return file;
})
);
// Create ZIP
const zipCreator = new SimpleZipCreator();
files.forEach(file => {
zipCreator.addFile(file.filename, file.content);
});
const zipData = zipCreator.create();
const buffer = Buffer.from(new Uint8Array(zipData));
// Return ZIP file
return new Response(buffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="code-examples-${Date.now()}.zip"`,
'Cache-Control': 'no-cache',
}
});
} catch (error) {
console.error('ZIP download error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: 'Failed to create zip file', details: errorMessage },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fileId = searchParams.get('id');
if (!fileId) {
return NextResponse.json(
{ error: 'id parameter is required' },
{ status: 400 }
);
}
const file = await FileExampleManager.getFileExample(fileId);
if (!file) {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
);
}
const encoder = new TextEncoder();
const content = encoder.encode(file.content);
const buffer = Buffer.from(content);
return new Response(buffer, {
headers: {
'Content-Type': getMimeType(file.language),
'Content-Disposition': `attachment; filename="${file.filename}"`,
'Cache-Control': 'no-cache',
}
});
} catch (error) {
console.error('File download error:', error);
return NextResponse.json(
{ error: 'Failed to download file' },
{ status: 500 }
);
}
}
// Helper function to get MIME type
function getMimeType(language: string): string {
const mimeTypes: Record<string, string> = {
'python': 'text/x-python',
'typescript': 'text/x-typescript',
'javascript': 'text/javascript',
'dockerfile': 'text/x-dockerfile',
'yaml': 'text/yaml',
'json': 'application/json',
'html': 'text/html',
'css': 'text/css',
'sql': 'text/x-sql',
'bash': 'text/x-shellscript',
'text': 'text/plain'
};
return mimeTypes[language] || 'text/plain';
}

View File

@@ -0,0 +1,89 @@
import { ImageResponse } from 'next/og';
import { blogPosts } from '../../../../src/data/blogPosts';
export const runtime = 'edge';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
const { slug: slugArray } = await params;
const slug = slugArray?.[0] || 'home';
let title: string;
let description: string;
if (slug === 'home') {
title = 'Marc Mintel';
description = 'Technical problem solver\'s blog - practical insights and learning notes';
} else {
const post = blogPosts.find(p => p.slug === slug);
title = post?.title || 'Marc Mintel';
description = (post?.description || 'Technical problem solver\'s blog - practical insights and learning notes').slice(0, 100);
}
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundColor: '#fff',
padding: '60px',
}}
>
<div
style={{
position: 'absolute',
top: '60px',
right: '60px',
width: '120px',
height: '4px',
backgroundColor: '#3b82f6',
borderRadius: '2px',
}}
/>
<div
style={{
fontSize: '48px',
fontWeight: 700,
color: '#1e293b',
marginBottom: '20px',
fontFamily: 'sans-serif',
}}
>
{title}
</div>
<div
style={{
fontSize: '24px',
fontWeight: 400,
color: '#64748b',
marginBottom: 'auto',
fontFamily: 'sans-serif',
}}
>
{description}
</div>
<div
style={{
fontSize: '18px',
fontWeight: 500,
color: '#94a3b8',
fontFamily: 'sans-serif',
}}
>
mintel.me
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}

215
app/blog/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,215 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { blogPosts } from '../../../src/data/blogPosts';
import { Tag } from '../../../src/components/Tag';
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
import { H2 } from '../../../src/components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
import { UL, LI } from '../../../src/components/ArticleList';
import { FileExamplesList } from '../../../src/components/FileExamplesList';
import { FileExampleManager } from '../../../src/data/fileExamples';
import { BlogPostClient } from '../../../src/components/BlogPostClient';
export async function generateStaticParams() {
return blogPosts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = blogPosts.find((p) => p.slug === slug);
if (!post) {
notFound();
}
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
const wordCount = post.description.split(/\s+/).length + 100;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
);
// Load file examples for the post
let groups: any[] = [];
if (showFileExamples) {
const allGroups = await FileExampleManager.getAllGroups();
groups = allGroups
.map((group) => ({
...group,
files: group.files.filter((file) => {
if (file.postSlug !== slug) return false;
return true;
}),
}))
.filter((group) => group.files.length > 0);
}
return (
<div className="min-h-screen bg-white">
<BlogPostClient readingTime={readingTime} title={post.title} />
<main id="post-content" className="pt-24">
<section className="py-12 md:py-16">
<div className="max-w-3xl mx-auto px-6">
<div className="text-center">
<h1 className="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight">
{post.title}
</h1>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans">
<time dateTime={post.date} className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
</svg>
{formattedDate}
</time>
<span className="text-slate-400"></span>
<span className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/>
</svg>
{readingTime} min
</span>
</div>
<p className="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto">
{post.description}
</p>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-8">
{post.tags.map((tag, index) => (
<Tag key={tag} tag={tag} index={index} className="text-xs" />
))}
</div>
)}
</div>
</div>
</section>
<section className="max-w-3xl mx-auto px-6 pb-24">
<div className="prose prose-slate max-w-none">
<p>{post.description}</p>
{slug === 'first-note' && (
<>
<LeadParagraph>
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
</LeadParagraph>
<H2>Why write in public?</H2>
<Paragraph>
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
</Paragraph>
<H2>What to expect</H2>
<UL>
<LI>Short entries, usually under 500 words</LI>
<LI>Practical solutions to specific problems</LI>
<LI>Notes on tools and workflows</LI>
<LI>Mistakes and what I learned</LI>
</UL>
</>
)}
{slug === 'debugging-tips' && (
<>
<LeadParagraph>
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
</LeadParagraph>
<H2>Why print statements work</H2>
<Paragraph>
Debuggers are powerful, but they change how your code runs. Print statements don't.
</Paragraph>
<CodeBlock language="python" showLineNumbers={true}>
{`def process_data(data):
print(f"Processing {len(data)} items")
result = expensive_operation(data)
print(f"Operation result: {result}")
return result`}
</CodeBlock>
<H2>Complete examples</H2>
<Paragraph>
Here are some practical file examples you can copy and download. These include proper error handling and logging.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>
</>
)}
{slug === 'architecture-patterns' && (
<>
<LeadParagraph>
Good software architecture is about making the right decisions early. Here are some patterns I've found useful in production systems.
</LeadParagraph>
<H2>Repository Pattern</H2>
<Paragraph>
The repository pattern provides a clean separation between your business logic and data access layer. It makes your code more testable and maintainable.
</Paragraph>
<H2>Service Layer</H2>
<Paragraph>
Services orchestrate business logic and coordinate between repositories and domain events. They keep your controllers thin and your business rules organized.
</Paragraph>
<H2>Domain Events</H2>
<Paragraph>
Domain events help you decouple components and react to changes in your system. They're essential for building scalable, event-driven architectures.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These TypeScript examples demonstrate modern architecture patterns for scalable applications. You can copy them directly into your project.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>
</>
)}
{slug === 'docker-deployment' && (
<>
<LeadParagraph>
Docker has become the standard for containerizing applications. Here's how to set up production-ready deployments that are secure, efficient, and maintainable.
</LeadParagraph>
<H2>Multi-stage builds</H2>
<Paragraph>
Multi-stage builds keep your production images small and secure by separating build and runtime environments. This reduces attack surface and speeds up deployments.
</Paragraph>
<H2>Health checks and monitoring</H2>
<Paragraph>
Proper health checks ensure your containers are running correctly. Combined with restart policies, this gives you resilient, self-healing deployments.
</Paragraph>
<H2>Orchestration with Docker Compose</H2>
<Paragraph>
Docker Compose makes it easy to manage multi-service applications in development and production. Define services, networks, and volumes in a single file.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These Docker configurations are production-ready. Use them as a starting point for your own deployments.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>
</>
)}
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import React from 'react';
import { Tag } from '../../../src/components/Tag';
import { H2 } from '../../../src/components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
import { UL, LI } from '../../../src/components/ArticleList';
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
import { YouTubeEmbed } from '../../../src/components/YouTubeEmbed';
import { TwitterEmbed } from '../../../src/components/TwitterEmbed';
import { GenericEmbed } from '../../../src/components/GenericEmbed';
import { Mermaid } from '../../../src/components/Mermaid';
import { BlogPostClient } from '../../../src/components/BlogPostClient';
export default function EmbedDemoPage() {
const post = {
title: "Rich Content Embedding Demo",
description: "Testing our new free embed components for YouTube, Twitter, Mermaid diagrams, and other platforms",
date: "2024-02-15",
slug: "embed-demo",
tags: ["embeds", "components", "tutorial", "mermaid"]
};
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
const readingTime = 5;
return (
<div className="min-h-screen bg-white">
<BlogPostClient readingTime={readingTime} title={post.title} />
<main id="post-content" className="pt-24">
<section className="py-12 md:py-16">
<div className="max-w-3xl mx-auto px-6">
<div className="text-center">
<h1 className="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight">
{post.title}
</h1>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans">
<time dateTime={post.date} className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
</svg>
{formattedDate}
</time>
<span className="text-slate-400"></span>
<span className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/>
</svg>
{readingTime} min
</span>
</div>
<p className="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto">
{post.description}
</p>
<div className="flex flex-wrap justify-center gap-2 mb-8">
{post.tags.map((tag, index) => (
<Tag key={tag} tag={tag} index={index} className="text-xs" />
))}
</div>
</div>
</div>
</section>
<section className="max-w-3xl mx-auto px-6 pb-24">
<div className="prose prose-slate max-w-none">
<LeadParagraph>
This post demonstrates our new free embed components that give you full styling control over YouTube videos, Twitter tweets, and other rich content - all generated at build time.
</LeadParagraph>
<H2>YouTube Embed Example</H2>
<Paragraph>
Here's a YouTube video embedded with full styling control. The component uses build-time generation for optimal performance.
</Paragraph>
<div className="my-6">
<YouTubeEmbed
videoId="dQw4w9WgXcQ"
title="Demo Video"
style="minimal"
className="my-4"
/>
</div>
<Paragraph>
You can customize the appearance using CSS variables or data attributes:
</Paragraph>
<CodeBlock
language="jsx"
showLineNumbers={true}
>
{`<YouTubeEmbed
videoId="dQw4w9WgXcQ"
style="minimal" // 'default' | 'minimal' | 'rounded' | 'flat'
aspectRatio="56.25%" // Custom aspect ratio
className="my-4" // Additional classes
/>`}
</CodeBlock>
<H2>Twitter/X Embed Example</H2>
<Paragraph>
Twitter embeds use the official Twitter iframe embed for reliable display.
</Paragraph>
<div className="my-4">
<TwitterEmbed
tweetId="20"
theme="light"
align="center"
/>
</div>
<CodeBlock
language="jsx"
showLineNumbers={true}
>
{`<TwitterEmbed
tweetId="20"
theme="light" // 'light' | 'dark'
align="center" // 'left' | 'center' | 'right'
/>`}
</CodeBlock>
<H2>Generic Embed Example</H2>
<Paragraph>
The generic component supports direct embeds for Vimeo, CodePen, GitHub Gists, and other platforms.
</Paragraph>
<div className="my-6">
<GenericEmbed
url="https://vimeo.com/123456789"
type="video"
maxWidth="800px"
/>
</div>
<CodeBlock
language="jsx"
showLineNumbers={true}
>
{`<GenericEmbed
url="https://vimeo.com/123456789"
type="video" // 'video' | 'article' | 'rich'
maxWidth="800px"
/>`}
</CodeBlock>
<H2>Mermaid Diagrams</H2>
<Paragraph>
We've added support for Mermaid diagrams! You can now create flowcharts, sequence diagrams, and more using a simple text-based syntax.
</Paragraph>
<div className="my-8">
<Mermaid
graph={`graph LR
A[Client] --> B[Load Balancer]
B --> C[App Server 1]
B --> D[App Server 2]
C --> E[(Database)]
D --> E`}
/>
</div>
<Paragraph>
Usage is straightforward:
</Paragraph>
<CodeBlock
language="jsx"
showLineNumbers={true}
>
{`<Mermaid
graph={\`graph LR
A[Client] --> B[Load Balancer]
B --> C[App Server 1]
B --> D[App Server 2]
C --> E[(Database)]
D --> E\`}
/>`}
</CodeBlock>
<H2>Styling Control</H2>
<Paragraph>
All components use CSS variables for easy customization:
</Paragraph>
<CodeBlock
language="css"
showLineNumbers={true}
>
{`.youtube-embed {
--aspect-ratio: 56.25%;
--bg-color: #000000;
--border-radius: 12px;
--shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Data attribute variations */
.youtube-embed[data-style="minimal"] {
--border-radius: 4px;
--shadow: none;
}`}
</CodeBlock>
<H2>Benefits</H2>
<UL>
<LI><strong>Free:</strong> No paid services required</LI>
<LI><strong>Fast:</strong> Build-time generation, no runtime API calls</LI>
<LI><strong>Flexible:</strong> Full styling control via CSS variables</LI>
<LI><strong>Self-hosted:</strong> Complete ownership and privacy</LI>
<LI><strong>SEO-friendly:</strong> Static HTML content</LI>
</UL>
<H2>Integration</H2>
<Paragraph>
Simply import the components in your blog posts:
</Paragraph>
<CodeBlock
language="jsx"
showLineNumbers={true}
>
{`import { YouTubeEmbed } from '../components/YouTubeEmbed';
import { TwitterEmbed } from '../components/TwitterEmbed';
import { GenericEmbed } from '../components/GenericEmbed';
<YouTubeEmbed videoId="abc123" style="rounded" />
<TwitterEmbed tweetId="123456789" theme="dark" />`}
</CodeBlock>
</div>
</section>
</main>
</div>
);
}

695
app/globals.css Normal file
View File

@@ -0,0 +1,695 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles - Tailwind only */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-slate-800 font-serif antialiased;
font-family: 'Georgia', 'Times New Roman', serif;
line-height: 1.75;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
@apply font-sans font-bold text-slate-900;
}
h1 {
@apply text-3xl md:text-4xl leading-tight mb-6;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h2 {
@apply text-2xl md:text-3xl leading-tight mb-4 mt-8;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h3 {
@apply text-xl md:text-2xl leading-tight mb-3 mt-6;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h4 {
@apply text-lg md:text-xl leading-tight mb-2 mt-4;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
p {
@apply mb-4 text-base leading-relaxed text-slate-700;
}
.lead {
@apply text-xl md:text-2xl text-slate-600 mb-6 leading-relaxed;
font-weight: 400;
}
a {
@apply text-blue-600 hover:text-blue-800 transition-colors;
}
ul, ol {
@apply ml-5 mb-4;
}
li {
@apply mb-1;
}
code:not([class*='language-']) {
@apply bg-slate-100 px-1 py-0.5 rounded font-mono text-sm text-slate-700;
}
blockquote {
@apply border-l-2 border-slate-300 pl-4 italic text-slate-600 my-4;
}
/* Focus states */
a:focus,
button:focus,
input:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
/* Components - Tailwind utility classes */
@layer components {
/* Legacy hooks required by tests */
.file-example {
@apply w-full;
}
.container {
@apply max-w-4xl mx-auto px-6 py-10;
}
.wide-container {
@apply max-w-5xl mx-auto px-6 py-12;
}
.narrow-container {
@apply max-w-2xl mx-auto px-6 py-8;
}
.highlighter-tag {
@apply inline-block text-xs font-bold px-2 py-0.5 rounded cursor-pointer transition-all duration-200;
position: relative;
transform: rotate(-1deg);
box-shadow: 2px 2px 0 rgba(0,0,0,0.1);
}
.search-box {
@apply w-full px-4 py-3 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-blue-400 transition-colors;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
}
.search-box::placeholder {
@apply text-slate-400;
}
/* Blog post card */
.post-card {
@apply mb-8 last:mb-0;
}
.post-meta {
@apply text-xs text-slate-500 font-sans mb-2;
}
.post-excerpt {
@apply text-slate-700 mb-2 leading-relaxed;
}
.post-tags {
@apply flex flex-wrap gap-1;
}
/* Article page */
.article-header {
@apply mb-8;
}
.article-title {
@apply text-4xl md:text-5xl font-bold mb-3;
}
.article-meta {
@apply text-sm text-slate-500 font-sans mb-5;
}
.article-content {
@apply text-lg leading-relaxed;
}
.article-content p {
@apply mb-5;
}
.article-content h2 {
@apply text-2xl font-bold mt-8 mb-3;
}
.article-content h3 {
@apply text-xl font-bold mt-6 mb-2;
}
.article-content ul,
.article-content ol {
@apply ml-6 mb-5;
}
.article-content li {
@apply mb-1;
}
.article-content blockquote {
@apply border-l-2 border-slate-400 pl-4 italic text-slate-600 my-5 text-lg;
}
/* Buttons */
.btn {
@apply inline-block px-4 py-2 bg-slate-900 text-white font-sans font-medium hover:bg-slate-700 transition-colors rounded;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition-colors;
}
.btn-secondary {
@apply bg-white text-slate-700 hover:bg-slate-100 border border-slate-300 px-3 py-1.5 rounded transition-colors;
}
/* Hide scrollbars */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Empty state */
.empty-state {
@apply text-center py-8 text-slate-500;
}
.empty-state svg {
@apply mx-auto mb-2 text-slate-300;
}
/* Line clamp utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Reading progress indicator */
.reading-progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: #3b82f6;
transform-origin: left;
transform: scaleX(0);
z-index: 50;
transition: transform 0.1s ease-out;
}
/* Floating back to top button */
.floating-back-to-top {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 2.5rem;
height: 2.5rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.15s ease;
opacity: 0;
transform: translateY(8px);
}
.floating-back-to-top.visible {
opacity: 1;
transform: translateY(0);
}
.floating-back-to-top:hover {
background: #f8fafc;
color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Print styles */
@media print {
.floating-back-to-top,
.reading-progress-bar {
display: none !important;
}
}
}
/* Additional global styles from BaseLayout */
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Selection color */
::selection {
background: #bfdbfe;
color: #1e40af;
}
/* Tag Styles */
.highlighter-tag {
transform: rotate(-1deg) translateY(0);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
animation: tagPopIn 0.3s ease-out both;
animation-delay: calc(var(--tag-index, 0) * 0.05s);
}
.highlighter-tag:hover {
transform: rotate(-2deg) translateY(-2px) scale(1.05);
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.15);
}
@keyframes tagPopIn {
from {
opacity: 0;
transform: rotate(-1deg) scale(0.8) translateY(5px);
}
to {
opacity: 1;
transform: rotate(-1deg) scale(1) translateY(0);
}
}
.highlighter-yellow {
background: linear-gradient(135deg, rgba(255, 235, 59, 0.95) 0%, rgba(255, 213, 79, 0.95) 100%);
color: #3f2f00;
}
.highlighter-pink {
background: linear-gradient(135deg, rgba(255, 167, 209, 0.95) 0%, rgba(255, 122, 175, 0.95) 100%);
color: #3f0018;
}
.highlighter-green {
background: linear-gradient(135deg, rgba(129, 199, 132, 0.95) 0%, rgba(102, 187, 106, 0.95) 100%);
color: #002f0a;
}
.highlighter-blue {
background: linear-gradient(135deg, rgba(100, 181, 246, 0.95) 0%, rgba(66, 165, 245, 0.95) 100%);
color: #001f3f;
}
.highlighter-tag:hover::before {
content: '';
position: absolute;
inset: -2px;
background: inherit;
filter: blur(8px);
opacity: 0.4;
z-index: -1;
border-radius: inherit;
}
.highlighter-tag:active {
transform: rotate(-1deg) translateY(0) scale(0.98);
transition: transform 0.1s ease;
}
.highlighter-tag:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
transform: rotate(-1deg) translateY(-2px) scale(1.05);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3), 3px 3px 0 rgba(0, 0, 0, 0.15);
}
/* Marker Title Styles */
.marker-title::before {
content: '';
position: absolute;
left: -0.15em;
right: -0.15em;
bottom: 0.05em;
height: 0.62em;
border-radius: 0.18em;
z-index: -1;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 20%,
rgba(253, 230, 138, 0.70) 20%,
rgba(253, 230, 138, 0.70) 100%
);
transform-origin: left center;
transform:
rotate(calc((var(--marker-seed, 0) - 3) * 0.45deg))
skewX(calc((var(--marker-seed, 0) - 3) * -0.25deg));
filter: saturate(1.05);
}
.marker-title::after {
content: '';
position: absolute;
left: -0.18em;
right: -0.05em;
bottom: 0.05em;
height: 0.62em;
border-radius: 0.18em;
z-index: -1;
background:
linear-gradient(
90deg,
rgba(253, 230, 138, 0.00) 0%,
rgba(253, 230, 138, 0.60) 8%,
rgba(253, 230, 138, 0.55) 60%,
rgba(253, 230, 138, 0.35) 100%
);
opacity: 0.75;
mix-blend-mode: multiply;
transform:
rotate(calc((var(--marker-seed, 0) - 3) * 0.45deg))
translateY(0.02em);
mask-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 20%,
rgba(0, 0, 0, 1) 100%
);
}
@media (prefers-reduced-motion: no-preference) {
.post-link:hover .marker-title::before,
.post-link:hover .marker-title::after {
filter: saturate(1.08) contrast(1.02);
}
}
/* Mermaid Styles */
.mermaid-container svg {
width: 100% !important;
max-width: 100%;
height: auto;
display: block;
background-color: transparent !important;
}
.mermaid-container rect,
.mermaid-container circle,
.mermaid-container ellipse,
.mermaid-container polygon,
.mermaid-container path,
.mermaid-container .actor,
.mermaid-container .node {
fill: white !important;
stroke: #cbd5e1 !important;
stroke-width: 1.5px !important;
}
.mermaid-container .edgePath .path,
.mermaid-container .messageLine0,
.mermaid-container .messageLine1,
.mermaid-container .flowchart-link {
stroke: #cbd5e1 !important;
stroke-width: 1.5px !important;
}
.mermaid-container text,
.mermaid-container .label,
.mermaid-container .labelText,
.mermaid-container .edgeLabel,
.mermaid-container .node text,
.mermaid-container tspan {
font-family: 'Inter', sans-serif !important;
fill: #334155 !important;
color: #334155 !important;
stroke: none !important;
font-size: 16px !important;
}
.mermaid-container .marker,
.mermaid-container marker path {
fill: #cbd5e1 !important;
stroke: #cbd5e1 !important;
}
/* Generic Embed Styles */
.generic-embed {
--max-width: 100%;
--border-radius: 8px;
--bg-color: #ffffff;
--border-color: #e2e8f0;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
margin: 1.5rem 0;
width: 100%;
max-width: var(--max-width);
}
.embed-wrapper {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background: var(--bg-color);
box-shadow: var(--shadow);
overflow: hidden;
transition: all 0.2s ease;
position: relative;
}
.generic-embed[data-type="video"] .embed-wrapper {
aspect-ratio: 16/9;
height: 0;
padding-bottom: 56.25%; /* 16:9 */
}
.generic-embed[data-type="video"] .embed-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.embed-wrapper:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border-color: #cbd5e1;
}
.generic-embed[data-provider="youtube.com"] {
--bg-color: #000000;
}
.generic-embed[data-provider="vimeo.com"] {
--bg-color: #1a1a1a;
}
.generic-embed[data-provider="codepen.io"] {
--bg-color: #1e1e1e;
--border-color: #333;
}
.embed-fallback {
padding: 1.5rem;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: var(--border-radius);
text-align: center;
}
.fallback-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: #64748b;
font-size: 0.875rem;
}
.fallback-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
margin-top: 0.25rem;
word-break: break-all;
}
.fallback-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.generic-embed {
margin: 1rem 0;
}
.embed-fallback {
padding: 1rem;
}
.embed-wrapper:hover {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
}
/* File Example Styles */
[data-file-example] {
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
.copy-btn,
.download-btn {
color: #475569;
}
.copy-btn[data-copied='true'] {
color: #065f46;
background: rgba(16, 185, 129, 0.10);
border-color: rgba(16, 185, 129, 0.35);
}
/* Prism.js syntax highlighting - light, low-noise */
code[class*='language-'],
pre[class*='language-'],
pre:has(code[class*='language-']) {
color: #0f172a;
background: transparent;
text-shadow: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8125rem;
line-height: 1.65;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 2;
hyphens: none;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #64748b;
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
}
.token.operator {
color: #64748b;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #c2410c;
}
.token.boolean,
.token.number {
color: #a16207;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #059669;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #475569;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #7c3aed;
font-weight: 500;
}
.token.function,
.token.class-name {
color: #2563eb;
}
.token.regex,
.token.important,
.token.variable {
color: #db2777;
}

40
app/layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Footer } from '../src/components/Footer';
import { InteractiveElements } from '../src/components/InteractiveElements';
import { Analytics } from '../src/components/Analytics';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export const metadata: Metadata = {
title: {
default: 'Marc Mintel',
template: '%s | Marc Mintel',
},
description: "Technical problem solver's blog - practical insights and learning notes",
metadataBase: new URL('https://mintel.me'),
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-screen bg-white">
<main className="container">
{children}
</main>
<Footer />
<InteractiveElements />
<Analytics />
</body>
</html>
);
}

191
app/page.tsx Normal file
View File

@@ -0,0 +1,191 @@
'use client';
import React, { useState, useEffect } from 'react';
import { MediumCard } from '../src/components/MediumCard';
import { SearchBar } from '../src/components/SearchBar';
import { Tag } from '../src/components/Tag';
import { blogPosts } from '../src/data/blogPosts';
export default function HomePage() {
const [searchQuery, setSearchQuery] = useState('');
const [filteredPosts, setFilteredPosts] = useState(blogPosts);
// Sort posts by date
const allPosts = [...blogPosts].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Get unique tags
const allTags = Array.from(new Set(allPosts.flatMap(post => post.tags || [])));
useEffect(() => {
const query = searchQuery.toLowerCase().trim();
if (query.startsWith('#')) {
const tag = query.slice(1);
setFilteredPosts(allPosts.filter(post =>
post.tags?.some(t => t.toLowerCase() === tag.toLowerCase())
));
} else {
setFilteredPosts(allPosts.filter(post => {
const title = post.title.toLowerCase();
const description = post.description.toLowerCase();
const tags = (post.tags || []).join(' ').toLowerCase();
return title.includes(query) || description.includes(query) || tags.includes(query);
}));
}
}, [searchQuery]);
const filterByTag = (tag: string) => {
setSearchQuery(`#${tag}`);
};
return (
<>
{/* Clean Hero Section */}
<section className="pt-10 pb-8 md:pt-12 md:pb-10 relative overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 bg-gradient-to-br from-white via-slate-50/30 to-blue-50/20 animate-gradient-shift"></div>
{/* Morphing Blob */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-48 h-48 bg-gradient-to-br from-blue-200/15 via-purple-200/10 to-indigo-200/15 animate-morph"></div>
</div>
{/* Animated Drawing Paths */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox="0 0 100 100">
<path d="M10,50 Q50,10 90,50 T90,90" stroke="rgba(59,130,246,0.1)" strokeWidth="0.5" fill="none" className="animate-draw"></path>
<path d="M10,70 Q50,30 90,70" stroke="rgba(147,51,234,0.1)" strokeWidth="0.5" fill="none" className="animate-draw-delay"></path>
<path d="M20,20 Q50,80 80,20" stroke="rgba(16,185,129,0.1)" strokeWidth="0.5" fill="none" className="animate-draw-reverse"></path>
</svg>
{/* Floating Shapes */}
<div className="absolute top-10 left-10 w-20 h-20 bg-blue-100/20 rounded-full animate-float-1"></div>
<div className="absolute top-20 right-20 w-16 h-16 bg-indigo-100/20 rotate-45 animate-float-2"></div>
<div className="absolute bottom-20 left-1/4 w-12 h-12 bg-purple-100/20 rounded-full animate-float-3"></div>
<div className="absolute bottom-10 right-1/3 w-24 h-24 bg-cyan-100/20 animate-float-4"></div>
<div className="max-w-3xl mx-auto px-6 relative z-10">
<div className="text-center animate-fade-in">
<h1 className="text-3xl md:text-4xl font-serif font-light text-slate-900 tracking-tight mb-3">
Marc Mintel
</h1>
<p className="text-base md:text-lg text-slate-600 leading-relaxed font-serif italic">
"A public notebook of things I figured out, mistakes I made, and tools I tested."
</p>
<div className="flex items-center justify-center gap-3 text-[13px] text-slate-500 font-sans mt-3">
<span className="inline-flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 12a4 4 0 110-8 4 4 0 010 8z"/></svg>
Vulkaneifel, Germany
</span>
<span aria-hidden="true"></span>
<span>Digital problem solver</span>
</div>
</div>
</div>
</section>
{/* Search */}
<section className="mb-8 mt-8">
<div id="search-container">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
</div>
</section>
{/* Topics */}
{allTags.length > 0 && (
<section className="mb-8">
<h2 className="text-lg font-semibold text-slate-800 mb-4">Topics</h2>
<div className="tag-cloud flex flex-wrap gap-2">
{allTags.map((tag, index) => (
<button
key={tag}
onClick={() => filterByTag(tag)}
className="inline-block"
>
<Tag tag={tag} index={index} />
</button>
))}
</div>
</section>
)}
{/* All Posts */}
<section>
<div id="posts-container" className="not-prose grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{filteredPosts.length === 0 ? (
<div className="empty-state col-span-full">
<p>No posts found matching your criteria.</p>
</div>
) : (
filteredPosts.map(post => (
<MediumCard key={post.slug} post={post} />
))
)}
</div>
</section>
<style jsx global>{`
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.animate-gradient-shift {
background-size: 200% 200%;
animation: gradient-shift 20s ease infinite;
}
@keyframes morph {
0%, 100% { border-radius: 50%; transform: scale(1) rotate(0deg); }
25% { border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%; transform: scale(1.1) rotate(90deg); }
50% { border-radius: 20% 80% 20% 80% / 80% 20% 80% 20%; transform: scale(0.9) rotate(180deg); }
75% { border-radius: 70% 30% 50% 50% / 50% 70% 30% 50%; transform: scale(1.05) rotate(270deg); }
}
.animate-morph {
animation: morph 25s ease-in-out infinite;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}
.animate-draw {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate;
}
.animate-draw-delay {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate 4s;
}
.animate-draw-reverse {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate-reverse 2s;
}
@keyframes float-1 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
.animate-float-1 { animation: float-1 15s ease-in-out infinite; }
@keyframes float-2 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(90deg); }
}
.animate-float-2 { animation: float-2 18s ease-in-out infinite; }
@keyframes float-3 {
0%, 100% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-10px) scale(1.1); }
}
.animate-float-3 { animation: float-3 12s ease-in-out infinite; }
@keyframes float-4 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-25px) rotate(-90deg); }
}
.animate-float-4 { animation: float-4 20s ease-in-out infinite; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in { animation: fadeIn 0.6s ease-out both; }
`}</style>
</>
);
}

41
app/tags/[tag]/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import Link from 'next/link';
import { blogPosts } from '../../../src/data/blogPosts';
import { MediumCard } from '../../../src/components/MediumCard';
export async function generateStaticParams() {
const allTags = Array.from(new Set(blogPosts.flatMap(post => post.tags || [])));
return allTags.map(tag => ({
tag,
}));
}
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params;
const posts = blogPosts.filter(post => post.tags?.includes(tag));
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold text-slate-900 mb-2">
Posts tagged <span className="highlighter-yellow px-2 rounded">{tag}</span>
</h1>
<p className="text-slate-600">
{posts.length} post{posts.length === 1 ? '' : 's'}
</p>
</header>
<div className="space-y-4">
{posts.map(post => (
<MediumCard key={post.slug} post={post} />
))}
</div>
<div className="mt-8 pt-6 border-t border-slate-200">
<Link href="/" className="text-blue-600 hover:text-blue-800 inline-flex items-center">
Back to home
</Link>
</div>
</div>
);
}