From 4906fc0b7b313036b799752987d02df78df3235b Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 29 Jan 2026 18:50:43 +0100 Subject: [PATCH] migrate to nextjs --- .gitignore | 18 + .../api/download-zip/route.ts | 74 +- app/api/og/[[...slug]]/route.tsx | 89 +++ app/blog/[slug]/page.tsx | 215 +++++ app/blog/embed-demo/page.tsx | 242 ++++++ app/globals.css | 695 ++++++++++++++++ app/layout.tsx | 40 + app/page.tsx | 191 +++++ app/tags/[tag]/page.tsx | 41 + astro.config.mjs | 15 - next.config.mjs | 6 + package-lock.json | 257 +++++- package.json | 14 +- src/components/Analytics.astro | 71 -- src/components/Analytics.tsx | 71 ++ src/components/BlogPostClient.tsx | 120 +++ src/components/Embeds/index.ts | 11 +- src/components/FileExample.astro | 337 -------- src/components/FileExample.tsx | 192 +++++ src/components/FileExamplesList.astro | 150 ---- src/components/FileExamplesList.tsx | 86 ++ src/components/GenericEmbed.astro | 204 ----- src/components/GenericEmbed.tsx | 98 +++ src/components/InteractiveElements.tsx | 53 ++ src/components/MediumCard.astro | 124 --- src/components/MediumCard.tsx | 66 ++ src/components/Mermaid.astro | 128 --- src/components/Mermaid.tsx | 87 ++ src/components/SearchBar.tsx | 79 +- src/components/Tag.astro | 119 --- src/components/Tag.tsx | 33 + src/components/TwitterEmbed.astro | 65 -- src/components/TwitterEmbed.tsx | 51 ++ src/components/YouTubeEmbed.astro | 32 - src/components/YouTubeEmbed.tsx | 42 + src/layouts/BaseLayout.astro | 227 ------ src/pages/api/og/[...slug].svg.ts | 72 -- src/pages/blog/[slug].astro | 754 ------------------ src/pages/blog/embed-demo.astro | 512 ------------ src/pages/index.astro | 533 ------------- src/pages/tags/[tag].astro | 42 - tailwind.config.js | 11 +- tsconfig.json | 43 +- 43 files changed, 2795 insertions(+), 3515 deletions(-) rename src/pages/api/download-zip.ts => app/api/download-zip/route.ts (82%) create mode 100644 app/api/og/[[...slug]]/route.tsx create mode 100644 app/blog/[slug]/page.tsx create mode 100644 app/blog/embed-demo/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/tags/[tag]/page.tsx delete mode 100644 astro.config.mjs create mode 100644 next.config.mjs delete mode 100644 src/components/Analytics.astro create mode 100644 src/components/Analytics.tsx create mode 100644 src/components/BlogPostClient.tsx delete mode 100644 src/components/FileExample.astro create mode 100644 src/components/FileExample.tsx delete mode 100644 src/components/FileExamplesList.astro create mode 100644 src/components/FileExamplesList.tsx delete mode 100644 src/components/GenericEmbed.astro create mode 100644 src/components/GenericEmbed.tsx create mode 100644 src/components/InteractiveElements.tsx delete mode 100644 src/components/MediumCard.astro create mode 100644 src/components/MediumCard.tsx delete mode 100644 src/components/Mermaid.astro create mode 100644 src/components/Mermaid.tsx delete mode 100644 src/components/Tag.astro create mode 100644 src/components/Tag.tsx delete mode 100644 src/components/TwitterEmbed.astro create mode 100644 src/components/TwitterEmbed.tsx delete mode 100644 src/components/YouTubeEmbed.astro create mode 100644 src/components/YouTubeEmbed.tsx delete mode 100644 src/layouts/BaseLayout.astro delete mode 100644 src/pages/api/og/[...slug].svg.ts delete mode 100644 src/pages/blog/[slug].astro delete mode 100644 src/pages/blog/embed-demo.astro delete mode 100644 src/pages/index.astro delete mode 100644 src/pages/tags/[tag].astro diff --git a/.gitignore b/.gitignore index 016b59e..b49b067 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # build output dist/ +.next/ +out/ # generated types .astro/ +next-env.d.ts # dependencies node_modules/ @@ -15,6 +18,10 @@ pnpm-debug.log* # environment variables .env +.env.local +.env.development.local +.env.test.local +.env.production.local .env.production # macOS-specific files @@ -22,3 +29,14 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# testing +/coverage diff --git a/src/pages/api/download-zip.ts b/app/api/download-zip/route.ts similarity index 82% rename from src/pages/api/download-zip.ts rename to app/api/download-zip/route.ts index 50f188c..ceee91d 100644 --- a/src/pages/api/download-zip.ts +++ b/app/api/download-zip/route.ts @@ -1,5 +1,5 @@ -import type { APIRoute } from 'astro'; -import { FileExampleManager } from '../../data/fileExamples'; +import { NextRequest, NextResponse } from 'next/server'; +import { FileExampleManager } from '../../../src/data/fileExamples'; // Simple ZIP creation without external dependencies class SimpleZipCreator { @@ -156,18 +156,15 @@ function intToLittleEndian(value: number, bytes: number): number[] { return result; } -export const POST: APIRoute = async ({ request }) => { +export async function POST(request: NextRequest) { try { const body = await request.json(); const { fileIds } = body; if (!Array.isArray(fileIds) || fileIds.length === 0) { - return new Response( - JSON.stringify({ error: 'fileIds array is required and must not be empty' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } + return NextResponse.json( + { error: 'fileIds array is required and must not be empty' }, + { status: 400 } ); } @@ -189,10 +186,10 @@ export const POST: APIRoute = async ({ request }) => { }); const zipData = zipCreator.create(); - const blob = new Blob([new Uint8Array(zipData)], { type: 'application/zip' }); + const buffer = Buffer.from(new Uint8Array(zipData)); // Return ZIP file - return new Response(blob, { + return new Response(buffer, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="code-examples-${Date.now()}.zip"`, @@ -202,51 +199,40 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('ZIP download error:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - - return new Response( - JSON.stringify({ error: 'Failed to create zip file', details: errorMessage }), - { - status: 500, - headers: { 'Content-Type': 'application/json' } - } + return NextResponse.json( + { error: 'Failed to create zip file', details: errorMessage }, + { status: 500 } ); } -}; +} -// Also support GET for single file download -export const GET: APIRoute = async ({ url }) => { +export async function GET(request: NextRequest) { try { - const fileId = url.searchParams.get('id'); + const { searchParams } = new URL(request.url); + const fileId = searchParams.get('id'); if (!fileId) { - return new Response( - JSON.stringify({ error: 'id parameter is required' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } + return NextResponse.json( + { error: 'id parameter is required' }, + { status: 400 } ); } const file = await FileExampleManager.getFileExample(fileId); if (!file) { - return new Response( - JSON.stringify({ error: 'File not found' }), - { - status: 404, - headers: { 'Content-Type': 'application/json' } - } + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } ); } const encoder = new TextEncoder(); const content = encoder.encode(file.content); - const blob = new Blob([content], { type: getMimeType(file.language) }); + const buffer = Buffer.from(content); - return new Response(blob, { + return new Response(buffer, { headers: { 'Content-Type': getMimeType(file.language), 'Content-Disposition': `attachment; filename="${file.filename}"`, @@ -256,16 +242,12 @@ export const GET: APIRoute = async ({ url }) => { } catch (error) { console.error('File download error:', error); - - return new Response( - JSON.stringify({ error: 'Failed to download file' }), - { - status: 500, - headers: { 'Content-Type': 'application/json' } - } + return NextResponse.json( + { error: 'Failed to download file' }, + { status: 500 } ); } -}; +} // Helper function to get MIME type function getMimeType(language: string): string { @@ -283,4 +265,4 @@ function getMimeType(language: string): string { 'text': 'text/plain' }; return mimeTypes[language] || 'text/plain'; -} \ No newline at end of file +} diff --git a/app/api/og/[[...slug]]/route.tsx b/app/api/og/[[...slug]]/route.tsx new file mode 100644 index 0000000..b7d8701 --- /dev/null +++ b/app/api/og/[[...slug]]/route.tsx @@ -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( + ( +
+
+
+ {title} +
+
+ {description} +
+
+ mintel.me +
+
+ ), + { + width: 1200, + height: 630, + } + ); +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..5584bbd --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -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 ( +
+ + +
+
+
+
+

+ {post.title} +

+ +
+ + + + + + + {readingTime} min + +
+ +

+ {post.description} +

+ + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag, index) => ( + + ))} +
+ )} +
+
+
+ +
+
+

{post.description}

+ + {slug === 'first-note' && ( + <> + + This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test. + +

Why write in public?

+ + I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else. + +

What to expect

+
    +
  • Short entries, usually under 500 words
  • +
  • Practical solutions to specific problems
  • +
  • Notes on tools and workflows
  • +
  • Mistakes and what I learned
  • +
+ + )} + + {slug === 'debugging-tips' && ( + <> + + Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need. + +

Why print statements work

+ + Debuggers are powerful, but they change how your code runs. Print statements don't. + + +{`def process_data(data): + print(f"Processing {len(data)} items") + result = expensive_operation(data) + print(f"Operation result: {result}") + return result`} + + +

Complete examples

+ + Here are some practical file examples you can copy and download. These include proper error handling and logging. + + +
+ +
+ + )} + + {slug === 'architecture-patterns' && ( + <> + + Good software architecture is about making the right decisions early. Here are some patterns I've found useful in production systems. + +

Repository Pattern

+ + The repository pattern provides a clean separation between your business logic and data access layer. It makes your code more testable and maintainable. + + +

Service Layer

+ + Services orchestrate business logic and coordinate between repositories and domain events. They keep your controllers thin and your business rules organized. + + +

Domain Events

+ + Domain events help you decouple components and react to changes in your system. They're essential for building scalable, event-driven architectures. + + +

Complete examples

+ + These TypeScript examples demonstrate modern architecture patterns for scalable applications. You can copy them directly into your project. + + +
+ +
+ + )} + + {slug === 'docker-deployment' && ( + <> + + Docker has become the standard for containerizing applications. Here's how to set up production-ready deployments that are secure, efficient, and maintainable. + +

Multi-stage builds

+ + Multi-stage builds keep your production images small and secure by separating build and runtime environments. This reduces attack surface and speeds up deployments. + + +

Health checks and monitoring

+ + Proper health checks ensure your containers are running correctly. Combined with restart policies, this gives you resilient, self-healing deployments. + + +

Orchestration with Docker Compose

+ + Docker Compose makes it easy to manage multi-service applications in development and production. Define services, networks, and volumes in a single file. + + +

Complete examples

+ + These Docker configurations are production-ready. Use them as a starting point for your own deployments. + + +
+ +
+ + )} +
+
+
+
+ ); +} diff --git a/app/blog/embed-demo/page.tsx b/app/blog/embed-demo/page.tsx new file mode 100644 index 0000000..7a4ea6d --- /dev/null +++ b/app/blog/embed-demo/page.tsx @@ -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 ( +
+ + +
+
+
+
+

+ {post.title} +

+ +
+ + + + + + + {readingTime} min + +
+ +

+ {post.description} +

+ +
+ {post.tags.map((tag, index) => ( + + ))} +
+
+
+
+ +
+
+ + 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. + + +

YouTube Embed Example

+ + Here's a YouTube video embedded with full styling control. The component uses build-time generation for optimal performance. + + +
+ +
+ + + You can customize the appearance using CSS variables or data attributes: + + + +{``} + + +

Twitter/X Embed Example

+ + Twitter embeds use the official Twitter iframe embed for reliable display. + + +
+ +
+ + +{``} + + +

Generic Embed Example

+ + The generic component supports direct embeds for Vimeo, CodePen, GitHub Gists, and other platforms. + + +
+ +
+ + +{``} + + +

Mermaid Diagrams

+ + We've added support for Mermaid diagrams! You can now create flowcharts, sequence diagrams, and more using a simple text-based syntax. + + +
+ B[Load Balancer] + B --> C[App Server 1] + B --> D[App Server 2] + C --> E[(Database)] + D --> E`} + /> +
+ + + Usage is straightforward: + + + +{` B[Load Balancer] + B --> C[App Server 1] + B --> D[App Server 2] + C --> E[(Database)] + D --> E\`} +/>`} + + +

Styling Control

+ + All components use CSS variables for easy customization: + + + +{`.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; +}`} + + +

Benefits

+
    +
  • Free: No paid services required
  • +
  • Fast: Build-time generation, no runtime API calls
  • +
  • Flexible: Full styling control via CSS variables
  • +
  • Self-hosted: Complete ownership and privacy
  • +
  • SEO-friendly: Static HTML content
  • +
+ +

Integration

+ + Simply import the components in your blog posts: + + + +{`import { YouTubeEmbed } from '../components/YouTubeEmbed'; +import { TwitterEmbed } from '../components/TwitterEmbed'; +import { GenericEmbed } from '../components/GenericEmbed'; + + +`} + +
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2f0ad42 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..c0e408e --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + +
+ {children} +
+