migrate to nextjs
This commit is contained in:
268
app/api/download-zip/route.ts
Normal file
268
app/api/download-zip/route.ts
Normal 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';
|
||||
}
|
||||
89
app/api/og/[[...slug]]/route.tsx
Normal file
89
app/api/og/[[...slug]]/route.tsx
Normal 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
215
app/blog/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
app/blog/embed-demo/page.tsx
Normal file
242
app/blog/embed-demo/page.tsx
Normal 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
695
app/globals.css
Normal 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
40
app/layout.tsx
Normal 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
191
app/page.tsx
Normal 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
41
app/tags/[tag]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user