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}
+
+
+
+
+
+
+
+ {formattedDate}
+
+
•
+
+
+
+
+ {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}
+
+
+
+
+
+
+
+ {formattedDate}
+
+
•
+
+
+
+
+ {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}
+
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..06c8467
--- /dev/null
+++ b/app/page.tsx
@@ -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 */}
+
+ {/* Animated Background */}
+
+
+ {/* Morphing Blob */}
+
+
+ {/* Animated Drawing Paths */}
+
+
+
+
+
+
+ {/* Floating Shapes */}
+
+
+
+
+
+
+
+
+ Marc Mintel
+
+
+ "A public notebook of things I figured out, mistakes I made, and tools I tested."
+
+
+
+
+ Vulkaneifel, Germany
+
+
•
+
Digital problem solver
+
+
+
+
+
+ {/* Search */}
+
+
+ {/* Topics */}
+ {allTags.length > 0 && (
+
+ Topics
+
+ {allTags.map((tag, index) => (
+ filterByTag(tag)}
+ className="inline-block"
+ >
+
+
+ ))}
+
+
+ )}
+
+ {/* All Posts */}
+
+
+ {filteredPosts.length === 0 ? (
+
+
No posts found matching your criteria.
+
+ ) : (
+ filteredPosts.map(post => (
+
+ ))
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx
new file mode 100644
index 0000000..d1b4699
--- /dev/null
+++ b/app/tags/[tag]/page.tsx
@@ -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 (
+
+
+
+
+ {posts.map(post => (
+
+ ))}
+
+
+
+
+ ← Back to home
+
+
+
+ );
+}
diff --git a/astro.config.mjs b/astro.config.mjs
deleted file mode 100644
index 12727db..0000000
--- a/astro.config.mjs
+++ /dev/null
@@ -1,15 +0,0 @@
-// @ts-check
-import { defineConfig } from 'astro/config';
-
-import react from '@astrojs/react';
-import mdx from '@astrojs/mdx';
-import sitemap from '@astrojs/sitemap';
-
-// https://astro.build/config
-export default defineConfig({
- site: 'https://mintel.me',
- integrations: [react(), mdx(), sitemap()],
- markdown: {
- syntaxHighlight: 'prism'
- }
-});
\ No newline at end of file
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..d5456a1
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index e0cb04c..55176ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",
"mermaid": "^11.12.2",
+ "next": "^16.1.6",
"prismjs": "^1.30.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
@@ -1710,6 +1711,140 @@
"langium": "3.3.1"
}
},
+ "node_modules/@next/env": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2204,6 +2339,15 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -3392,6 +3536,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -6722,6 +6872,87 @@
"node": ">= 10"
}
},
+ "node_modules/next": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.6",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/nlcst-to-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
@@ -8067,6 +8298,29 @@
"inline-style-parser": "0.2.7"
}
},
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -8374,8 +8628,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
diff --git a/package.json b/package.json
index ac4d9b3..ef8018d 100644
--- a/package.json
+++ b/package.json
@@ -4,28 +4,24 @@
"version": "0.1.0",
"description": "Technical problem solver's blog - practical insights and learning notes",
"scripts": {
- "dev": "astro dev",
- "build": "astro build",
- "preview": "astro preview",
- "astro": "astro",
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
"test": "npm run test:smoke",
"test:smoke": "tsx ./scripts/smoke-test.ts",
"test:links": "tsx ./scripts/test-links.ts",
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts"
},
"dependencies": {
- "@astrojs/mdx": "^4.3.13",
- "@astrojs/react": "^4.4.2",
- "@astrojs/sitemap": "^3.6.1",
- "@astrojs/tailwind": "^6.0.2",
"@types/ioredis": "^4.28.10",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vercel/og": "^0.8.6",
- "astro": "^5.16.8",
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",
"mermaid": "^11.12.2",
+ "next": "^16.1.6",
"prismjs": "^1.30.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
diff --git a/src/components/Analytics.astro b/src/components/Analytics.astro
deleted file mode 100644
index 9a43958..0000000
--- a/src/components/Analytics.astro
+++ /dev/null
@@ -1,71 +0,0 @@
----
-// Analytics Component
-// Uses clean service pattern with dependency injection
-import { createPlausibleAnalytics } from '../utils/analytics';
-
-const { domain = 'mintel.me', scriptUrl = 'https://plausible.yourdomain.com/js/script.js' } = Astro.props;
-
-// Create service instance
-const analytics = createPlausibleAnalytics({ domain, scriptUrl });
-const adapter = analytics.getAdapter();
-const scriptTag = (adapter as any).getScriptTag?.() || '';
----
-
-
-{scriptTag && }
-
-
-
\ No newline at end of file
diff --git a/src/components/Analytics.tsx b/src/components/Analytics.tsx
new file mode 100644
index 0000000..16d51ff
--- /dev/null
+++ b/src/components/Analytics.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import React, { useEffect } from 'react';
+import { createPlausibleAnalytics } from '../utils/analytics';
+
+interface AnalyticsProps {
+ domain?: string;
+ scriptUrl?: string;
+}
+
+export const Analytics: React.FC = ({
+ domain = 'mintel.me',
+ scriptUrl = 'https://plausible.yourdomain.com/js/script.js'
+}) => {
+ useEffect(() => {
+ const analytics = createPlausibleAnalytics({
+ domain: document.documentElement.lang || domain,
+ scriptUrl
+ });
+
+ // Track page load performance
+ const trackPageLoad = () => {
+ const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
+ if (perfData && typeof perfData.loadEventEnd === 'number' && typeof perfData.startTime === 'number') {
+ const loadTime = perfData.loadEventEnd - perfData.startTime;
+ analytics.trackPageLoad(
+ loadTime,
+ window.location.pathname,
+ navigator.userAgent
+ );
+ }
+ };
+
+ // Track outbound links
+ const trackOutboundLinks = () => {
+ document.querySelectorAll('a[href^="http"]').forEach(link => {
+ const anchor = link as HTMLAnchorElement;
+ if (!anchor.href.includes(window.location.hostname)) {
+ anchor.addEventListener('click', () => {
+ analytics.trackOutboundLink(anchor.href, anchor.textContent?.trim() || 'unknown');
+ });
+ }
+ });
+ };
+
+ // Track search
+ const trackSearch = () => {
+ const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
+ if (searchInput) {
+ const handleSearch = (e: Event) => {
+ const target = e.target as HTMLInputElement;
+ if (target.value) {
+ analytics.trackSearch(target.value, window.location.pathname);
+ }
+ };
+ searchInput.addEventListener('search', handleSearch);
+ return () => searchInput.removeEventListener('search', handleSearch);
+ }
+ };
+
+ trackPageLoad();
+ trackOutboundLinks();
+ const cleanupSearch = trackSearch();
+
+ return () => {
+ if (cleanupSearch) cleanupSearch();
+ };
+ }, [domain, scriptUrl]);
+
+ return null;
+};
diff --git a/src/components/BlogPostClient.tsx b/src/components/BlogPostClient.tsx
new file mode 100644
index 0000000..7230dbb
--- /dev/null
+++ b/src/components/BlogPostClient.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+interface BlogPostClientProps {
+ readingTime: number;
+ title: string;
+}
+
+export const BlogPostClient: React.FC = ({ readingTime, title }) => {
+ const router = useRouter();
+ const [isScrolled, setIsScrolled] = useState(false);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ setIsScrolled(window.scrollY > 100);
+ };
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const handleBack = () => {
+ // Lovely exit animation
+ const content = document.getElementById('post-content');
+ if (content) {
+ content.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
+ content.style.opacity = '0';
+ content.style.transform = 'translateY(20px) scale(0.98)';
+ }
+
+ const topNav = document.getElementById('top-nav');
+ if (topNav) {
+ topNav.style.transition = 'opacity 0.4s ease-out';
+ topNav.style.opacity = '0';
+ }
+
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 bg-gradient-to-br from-white via-slate-50 to-white z-[100] opacity-0 transition-opacity duration-500';
+ document.body.appendChild(overlay);
+
+ setTimeout(() => {
+ overlay.style.opacity = '1';
+ }, 100);
+
+ setTimeout(() => {
+ router.push('/?from=post');
+ }, 500);
+ };
+
+ const handleShare = async () => {
+ const url = window.location.href;
+ if (navigator.share) {
+ try {
+ await navigator.share({ title, url });
+ } catch (err) {
+ console.error('Share failed:', err);
+ }
+ } else {
+ // Fallback: copy to clipboard
+ try {
+ await navigator.clipboard.writeText(url);
+ alert('Link copied to clipboard!');
+ } catch (err) {
+ console.error('Copy failed:', err);
+ }
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ Back
+
+
+
+
+ {readingTime} min read
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to all posts
+
+
+ >
+ );
+};
diff --git a/src/components/Embeds/index.ts b/src/components/Embeds/index.ts
index 7ce935e..62fd3cc 100644
--- a/src/components/Embeds/index.ts
+++ b/src/components/Embeds/index.ts
@@ -1,11 +1,10 @@
// Embed Components Index
-// Note: Astro components are default exported, import them directly
// Re-export for convenience
-export { default as YouTubeEmbed } from '../YouTubeEmbed.astro';
-export { default as TwitterEmbed } from '../TwitterEmbed.astro';
-export { default as GenericEmbed } from '../GenericEmbed.astro';
-export { default as Mermaid } from '../Mermaid.astro';
+export { YouTubeEmbed } from '../YouTubeEmbed';
+export { TwitterEmbed } from '../TwitterEmbed';
+export { GenericEmbed } from '../GenericEmbed';
+export { Mermaid } from '../Mermaid';
// Type definitions for props
export interface MermaidProps {
@@ -33,4 +32,4 @@ export interface GenericEmbedProps {
className?: string;
maxWidth?: string;
type?: 'video' | 'article' | 'rich';
-}
\ No newline at end of file
+}
diff --git a/src/components/FileExample.astro b/src/components/FileExample.astro
deleted file mode 100644
index 7b2f9e0..0000000
--- a/src/components/FileExample.astro
+++ /dev/null
@@ -1,337 +0,0 @@
----
-// FileExample.astro - Static file display component with syntax highlighting
-import Prism from 'prismjs';
-import 'prismjs/components/prism-python';
-import 'prismjs/components/prism-typescript';
-import 'prismjs/components/prism-javascript';
-import 'prismjs/components/prism-jsx';
-import 'prismjs/components/prism-tsx';
-import 'prismjs/components/prism-docker';
-import 'prismjs/components/prism-yaml';
-import 'prismjs/components/prism-json';
-import 'prismjs/components/prism-markup';
-import 'prismjs/components/prism-css';
-import 'prismjs/components/prism-sql';
-import 'prismjs/components/prism-bash';
-import 'prismjs/components/prism-markdown';
-
-interface Props {
- filename: string;
- content: string;
- language: string;
- description?: string;
- tags?: string[];
- id: string;
-}
-
-const { filename, content, language, id } = Astro.props;
-
-const safeId = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
-const headerId = `file-example-header-${safeId}`;
-const contentId = `file-example-content-${safeId}`;
-
-const fileExtension = filename.split('.').pop() || language;
-
-const prismLanguageMap: Record = {
- py: 'python',
- ts: 'typescript',
- tsx: 'tsx',
- js: 'javascript',
- jsx: 'jsx',
- dockerfile: 'docker',
- docker: 'docker',
- yml: 'yaml',
- yaml: 'yaml',
- json: 'json',
- html: 'markup',
- css: 'css',
- sql: 'sql',
- sh: 'bash',
- bash: 'bash',
- md: 'markdown',
-};
-
-const prismLanguage = prismLanguageMap[fileExtension] || 'markup';
-
-const highlightedCode = Prism.highlight(
- content,
- Prism.languages[prismLanguage] || Prism.languages.markup,
- prismLanguage,
-);
----
-
-
-
-
-
-
diff --git a/src/components/FileExample.tsx b/src/components/FileExample.tsx
new file mode 100644
index 0000000..6c3a2a6
--- /dev/null
+++ b/src/components/FileExample.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import React, { useState, useRef } from 'react';
+import * as Prism from 'prismjs';
+import 'prismjs/components/prism-python';
+import 'prismjs/components/prism-typescript';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-jsx';
+import 'prismjs/components/prism-tsx';
+import 'prismjs/components/prism-docker';
+import 'prismjs/components/prism-yaml';
+import 'prismjs/components/prism-json';
+import 'prismjs/components/prism-markup';
+import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-sql';
+import 'prismjs/components/prism-bash';
+import 'prismjs/components/prism-markdown';
+
+interface FileExampleProps {
+ filename: string;
+ content: string;
+ language: string;
+ description?: string;
+ tags?: string[];
+ id: string;
+}
+
+const prismLanguageMap: Record = {
+ py: 'python',
+ ts: 'typescript',
+ tsx: 'tsx',
+ js: 'javascript',
+ jsx: 'jsx',
+ dockerfile: 'docker',
+ docker: 'docker',
+ yml: 'yaml',
+ yaml: 'yaml',
+ json: 'json',
+ html: 'markup',
+ css: 'css',
+ sql: 'sql',
+ sh: 'bash',
+ bash: 'bash',
+ md: 'markdown',
+};
+
+export const FileExample: React.FC = ({
+ filename,
+ content,
+ language,
+ id
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isCopied, setIsCopied] = useState(false);
+ const contentRef = useRef(null);
+
+ const safeId = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
+ const headerId = `file-example-header-${safeId}`;
+ const contentId = `file-example-content-${safeId}`;
+
+ const fileExtension = filename.split('.').pop() || language;
+ const prismLanguage = prismLanguageMap[fileExtension] || 'markup';
+
+ const highlightedCode = Prism.highlight(
+ content,
+ Prism.languages[prismLanguage] || Prism.languages.markup,
+ prismLanguage,
+ );
+
+ const toggleExpand = () => {
+ setIsExpanded(!isExpanded);
+ if (!isExpanded) {
+ setTimeout(() => {
+ contentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 120);
+ }
+ };
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await navigator.clipboard.writeText(content);
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 900);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ const handleDownload = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const blob = new Blob([content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleExpand();
+ }
+ }}
+ aria-expanded={isExpanded}
+ aria-controls={contentId}
+ id={headerId}
+ >
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/FileExamplesList.astro b/src/components/FileExamplesList.astro
deleted file mode 100644
index d1696e9..0000000
--- a/src/components/FileExamplesList.astro
+++ /dev/null
@@ -1,150 +0,0 @@
----
-// FileExamplesList.astro - Static component that renders at build time
-import { FileExampleManager, type FileExampleGroup } from '../data/fileExamples';
-import FileExample from './FileExample.astro';
-
-interface Props {
- groupId?: string;
- showAll?: boolean;
- tags?: string[];
- postSlug?: string;
-}
-
-const { groupId, showAll = false, tags, postSlug } = Astro.props;
-
-// Load and filter file examples at build time (no loading state needed)
-let groups: FileExampleGroup[] = [];
-
-try {
- if (postSlug) {
- const allGroups = await FileExampleManager.getAllGroups();
- groups = allGroups
- .map((group) => ({
- ...group,
- files: group.files.filter((file) => {
- if (file.postSlug !== postSlug) return false;
- if (groupId && group.groupId !== groupId) return false;
- if (tags && tags.length > 0) return file.tags?.some((tag) => tags.includes(tag));
- return true;
- }),
- }))
- .filter((group) => group.files.length > 0);
- } else if (groupId) {
- const group = await FileExampleManager.getGroup(groupId);
- groups = group ? [group] : [];
- } else if (tags && tags.length > 0) {
- const allGroups = await FileExampleManager.getAllGroups();
- groups = allGroups
- .map((group) => ({
- ...group,
- files: group.files.filter((file) => tags.some((tag) => file.tags?.includes(tag))),
- }))
- .filter((group) => group.files.length > 0);
- } else if (showAll) {
- groups = await FileExampleManager.getAllGroups();
- } else {
- groups = await FileExampleManager.getAllGroups();
- }
-} catch (error) {
- console.error('Error loading file examples:', error);
- groups = [];
-}
----
-
-{groups.length === 0 ? (
-
-) : (
-
- {groups.map((group) => (
-
-
- {group.title}
-
-
-
- {group.files.length} files
-
-
-
-
-
-
-
-
-
-
-
- {group.files.map((file) => (
-
- ))}
-
-
- ))}
-
-)}
-
-
diff --git a/src/components/FileExamplesList.tsx b/src/components/FileExamplesList.tsx
new file mode 100644
index 0000000..80919e7
--- /dev/null
+++ b/src/components/FileExamplesList.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import React, { useState } from 'react';
+import { FileExample } from './FileExample';
+import type { FileExampleGroup } from '../data/fileExamples';
+
+interface FileExamplesListProps {
+ groups: FileExampleGroup[];
+}
+
+export const FileExamplesList: React.FC = ({ groups }) => {
+ const [expandedGroups, setExpandedGroups] = useState>({});
+
+ const toggleAllInGroup = (groupId: string, files: any[]) => {
+ const isAnyExpanded = files.some(f => expandedGroups[f.id]);
+ const newExpanded = { ...expandedGroups };
+ files.forEach(f => {
+ newExpanded[f.id] = !isAnyExpanded;
+ });
+ setExpandedGroups(newExpanded);
+ };
+
+ if (groups.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {groups.map((group) => (
+
+
+ {group.title}
+
+
+
+ {group.files.length} files
+
+
+
toggleAllInGroup(group.groupId, group.files)}
+ >
+ {group.files.some(f => expandedGroups[f.id]) ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {group.files.map((file) => (
+
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/GenericEmbed.astro b/src/components/GenericEmbed.astro
deleted file mode 100644
index 713af37..0000000
--- a/src/components/GenericEmbed.astro
+++ /dev/null
@@ -1,204 +0,0 @@
----
-// GenericEmbed.astro - Universal embed component using direct iframes
-interface Props {
- url: string;
- className?: string;
- maxWidth?: string;
- type?: 'video' | 'article' | 'rich';
-}
-
-const {
- url,
- className = "",
- maxWidth = "100%",
- type = 'rich'
-} = Astro.props;
-
-// Detect provider and create direct embed URLs
-let embedUrl: string | null = null;
-let provider = 'unknown';
-
-try {
- const urlObj = new URL(url);
- const hostname = urlObj.hostname.replace('www.', '');
-
- // YouTube
- if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
- const videoId = urlObj.searchParams.get('v') || urlObj.pathname.split('/').pop();
- if (videoId) {
- embedUrl = `https://www.youtube.com/embed/${videoId}`;
- provider = 'youtube.com';
- }
- }
- // Vimeo
- else if (hostname.includes('vimeo.com')) {
- const videoId = urlObj.pathname.split('/').filter(Boolean)[0];
- if (videoId) {
- embedUrl = `https://player.vimeo.com/video/${videoId}`;
- provider = 'vimeo.com';
- }
- }
- // CodePen
- else if (hostname.includes('codepen.io')) {
- const penPath = urlObj.pathname.replace('/pen/', '/');
- embedUrl = `https://codepen.io${penPath}?default-tab=html,result`;
- provider = 'codepen.io';
- }
- // GitHub Gist
- else if (hostname.includes('gist.github.com')) {
- const gistPath = urlObj.pathname;
- embedUrl = `https://gist.github.com${gistPath}.js`;
- provider = 'gist.github.com';
- }
-} catch (e) {
- console.warn('GenericEmbed: Failed to parse URL', e);
-}
-
-// Fallback to simple link
-const hasEmbed = embedUrl !== null;
----
-
-
- {hasEmbed ? (
-
- {type === 'video' ? (
-
- ) : (
-
- )}
-
- ) : (
-
- )}
-
-
-
\ No newline at end of file
diff --git a/src/components/GenericEmbed.tsx b/src/components/GenericEmbed.tsx
new file mode 100644
index 0000000..fc0be4d
--- /dev/null
+++ b/src/components/GenericEmbed.tsx
@@ -0,0 +1,98 @@
+import React from 'react';
+
+interface GenericEmbedProps {
+ url: string;
+ className?: string;
+ maxWidth?: string;
+ type?: 'video' | 'article' | 'rich';
+}
+
+export const GenericEmbed: React.FC = ({
+ url,
+ className = "",
+ maxWidth = "100%",
+ type = 'rich'
+}) => {
+ let embedUrl: string | null = null;
+ let provider = 'unknown';
+
+ try {
+ const urlObj = new URL(url);
+ const hostname = urlObj.hostname.replace('www.', '');
+
+ if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
+ const videoId = urlObj.searchParams.get('v') || urlObj.pathname.split('/').pop();
+ if (videoId) {
+ embedUrl = `https://www.youtube.com/embed/${videoId}`;
+ provider = 'youtube.com';
+ }
+ }
+ else if (hostname.includes('vimeo.com')) {
+ const videoId = urlObj.pathname.split('/').filter(Boolean)[0];
+ if (videoId) {
+ embedUrl = `https://player.vimeo.com/video/${videoId}`;
+ provider = 'vimeo.com';
+ }
+ }
+ else if (hostname.includes('codepen.io')) {
+ const penPath = urlObj.pathname.replace('/pen/', '/');
+ embedUrl = `https://codepen.io${penPath}?default-tab=html,result`;
+ provider = 'codepen.io';
+ }
+ else if (hostname.includes('gist.github.com')) {
+ const gistPath = urlObj.pathname;
+ embedUrl = `https://gist.github.com${gistPath}.js`;
+ provider = 'gist.github.com';
+ }
+ } catch (e) {
+ console.warn('GenericEmbed: Failed to parse URL', e);
+ }
+
+ const hasEmbed = embedUrl !== null;
+
+ return (
+
+ {hasEmbed ? (
+
+ {type === 'video' ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/components/InteractiveElements.tsx b/src/components/InteractiveElements.tsx
new file mode 100644
index 0000000..d8f98fb
--- /dev/null
+++ b/src/components/InteractiveElements.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+
+export const InteractiveElements: React.FC = () => {
+ const [progress, setProgress] = useState(0);
+ const [showBackToTop, setShowBackToTop] = useState(false);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const scrollTop = window.scrollY;
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight;
+
+ if (docHeight > 0) {
+ setProgress((scrollTop / docHeight) * 100);
+ }
+
+ setShowBackToTop(scrollTop > 300);
+ };
+
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const scrollToTop = () => {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ };
+
+ return (
+ <>
+ {/* Reading Progress Bar */}
+
+
+ {/* Floating Back to Top Button */}
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/MediumCard.astro b/src/components/MediumCard.astro
deleted file mode 100644
index 6694cdc..0000000
--- a/src/components/MediumCard.astro
+++ /dev/null
@@ -1,124 +0,0 @@
----
-interface Props {
- post: {
- title: string;
- description: string;
- date: string;
- slug: string;
- tags?: string[];
- };
-}
-
-const { post } = Astro.props;
-const { title, description, date, tags = [] } = post;
-
-const formattedDate = new Date(date).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
-});
-
-const wordCount = description.split(/\s+/).length;
-const readingTime = Math.max(1, Math.ceil(wordCount / 200));
----
-
-
-
-
- acc + c.charCodeAt(0), 0)) % 7};`}>
- {title}
-
-
-
- {formattedDate}
-
-
-
-
- {description}
-
-
-
-
{readingTime} min
-
- {tags.length > 0 && (
-
- {tags.slice(0, 3).map((tag: string) => (
-
- {tag}
-
- ))}
-
- )}
-
-
-
-
diff --git a/src/components/MediumCard.tsx b/src/components/MediumCard.tsx
new file mode 100644
index 0000000..49a8f51
--- /dev/null
+++ b/src/components/MediumCard.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import Link from 'next/link';
+
+interface Post {
+ title: string;
+ description: string;
+ date: string;
+ slug: string;
+ tags?: string[];
+}
+
+interface MediumCardProps {
+ post: Post;
+}
+
+export const MediumCard: React.FC = ({ post }) => {
+ const { title, description, date, slug, tags = [] } = post;
+
+ const formattedDate = new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+ const wordCount = description.split(/\s+/).length;
+ const readingTime = Math.max(1, Math.ceil(wordCount / 200));
+ const markerSeed = Math.abs(title.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)) % 7;
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+ {formattedDate}
+
+
+
+
+ {description}
+
+
+
+
{readingTime} min
+
+ {tags.length > 0 && (
+
+ {tags.slice(0, 3).map((tag: string) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Mermaid.astro b/src/components/Mermaid.astro
deleted file mode 100644
index a99f68d..0000000
--- a/src/components/Mermaid.astro
+++ /dev/null
@@ -1,128 +0,0 @@
----
-interface Props {
- graph: string;
- id?: string;
-}
-
-const { graph, id = `mermaid-${Math.random().toString(36).substring(2, 11)}` } = Astro.props;
----
-
-
-
-
-
-
diff --git a/src/components/Mermaid.tsx b/src/components/Mermaid.tsx
new file mode 100644
index 0000000..b930996
--- /dev/null
+++ b/src/components/Mermaid.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import React, { useEffect, useRef, useState } from 'react';
+import mermaid from 'mermaid';
+
+interface MermaidProps {
+ graph: string;
+ id?: string;
+}
+
+export const Mermaid: React.FC = ({ graph, id: providedId }) => {
+ const [id, setId] = useState(null);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ setId(providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`);
+ }, [providedId]);
+ const [isRendered, setIsRendered] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: 'default',
+ darkMode: false,
+ themeVariables: {
+ fontFamily: 'Inter, system-ui, sans-serif',
+ fontSize: '16px',
+ primaryColor: '#ffffff',
+ nodeBorder: '#e2e8f0',
+ mainBkg: '#ffffff',
+ lineColor: '#cbd5e1',
+ },
+ securityLevel: 'loose',
+ });
+
+ const render = async () => {
+ if (containerRef.current && id) {
+ try {
+ const { svg } = await mermaid.render(`${id}-svg`, graph);
+ containerRef.current.innerHTML = svg;
+ setIsRendered(true);
+ } catch (err) {
+ console.error('Mermaid rendering failed:', err);
+ setError('Failed to render diagram. Please check the syntax.');
+ setIsRendered(true);
+ }
+ }
+ };
+
+ if (id) {
+ render();
+ }
+ }, [graph, id]);
+
+ if (!id) return null;
+
+ return (
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : (
+ graph
+ )}
+
+
+
+
+ );
+};
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
index 870a8a5..3923a54 100644
--- a/src/components/SearchBar.tsx
+++ b/src/components/SearchBar.tsx
@@ -1,62 +1,36 @@
-import React, { useState, useRef, useEffect } from 'react';
+'use client';
-export const SearchBar: React.FC = () => {
- const [query, setQuery] = useState('');
+import React, { useState, useRef } from 'react';
+
+interface SearchBarProps {
+ value?: string;
+ onChange?: (value: string) => void;
+}
+
+export const SearchBar: React.FC = ({ value: propValue, onChange }) => {
+ const [internalValue, setInternalValue] = useState('');
const inputRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
+ const value = propValue !== undefined ? propValue : internalValue;
+
const handleInput = (e: React.ChangeEvent) => {
- const value = e.target.value;
- setQuery(value);
-
- // Trigger search functionality
- if (typeof window !== 'undefined') {
- const postsContainer = document.getElementById('posts-container');
- const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf;
-
- if (!allPosts) return;
-
- const queryLower = value.toLowerCase().trim();
-
- allPosts.forEach((post: HTMLElement) => {
- const title = post.querySelector('h3')?.textContent?.toLowerCase() || '';
- const description = post.querySelector('.post-excerpt')?.textContent?.toLowerCase() || '';
- const tags = Array.from(post.querySelectorAll('.highlighter-tag'))
- .map(tag => tag.textContent?.toLowerCase() || '')
- .join(' ');
-
- const searchableText = `${title} ${description} ${tags}`;
-
- if (searchableText.includes(queryLower) || queryLower === '') {
- post.style.display = 'block';
- post.style.opacity = '1';
- post.style.transform = 'scale(1)';
- } else {
- post.style.display = 'none';
- post.style.opacity = '0';
- post.style.transform = 'scale(0.95)';
- }
- });
+ const newValue = e.target.value;
+ if (onChange) {
+ onChange(newValue);
+ } else {
+ setInternalValue(newValue);
}
};
const clearSearch = () => {
- setQuery('');
- if (inputRef.current) {
- inputRef.current.value = '';
- inputRef.current.focus();
+ if (onChange) {
+ onChange('');
+ } else {
+ setInternalValue('');
}
-
- // Reset all posts
- if (typeof window !== 'undefined') {
- const postsContainer = document.getElementById('posts-container');
- const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf;
-
- allPosts?.forEach((post: HTMLElement) => {
- post.style.display = 'block';
- post.style.opacity = '1';
- post.style.transform = 'scale(1)';
- });
+ if (inputRef.current) {
+ inputRef.current.focus();
}
};
@@ -74,17 +48,18 @@ export const SearchBar: React.FC = () => {
ref={inputRef}
type="text"
placeholder="Search"
+ value={value}
className={`w-full px-3 py-2 text-[14px] border border-slate-200 rounded-md bg-transparent transition-colors font-sans focus:outline-none ${
isFocused ? 'bg-white border-slate-300' : 'hover:border-slate-300'
}`}
- onInput={handleInput}
+ onChange={handleInput}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
aria-label="Search blog posts"
/>
- {query && (
+ {value && (
{
);
-};
\ No newline at end of file
+};
diff --git a/src/components/Tag.astro b/src/components/Tag.astro
deleted file mode 100644
index fd23f53..0000000
--- a/src/components/Tag.astro
+++ /dev/null
@@ -1,119 +0,0 @@
----
-interface Props {
- tag: string;
- index: number;
- className?: string;
-}
-
-const { tag, index, className = '' } = Astro.props;
-
-// Color mapping based on tag content for variety
-const getColorClass = (tag: string) => {
- const tagLower = tag.toLowerCase();
- if (tagLower.includes('meta') || tagLower.includes('learning')) return 'highlighter-yellow';
- if (tagLower.includes('debug') || tagLower.includes('tools')) return 'highlighter-pink';
- if (tagLower.includes('ai') || tagLower.includes('automation')) return 'highlighter-blue';
- if (tagLower.includes('script') || tagLower.includes('code')) return 'highlighter-green';
- return 'highlighter-yellow'; // default
-};
-
-const colorClass = getColorClass(tag);
----
-
-
- {tag}
-
-
-
-
-
\ No newline at end of file
diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx
new file mode 100644
index 0000000..c78f988
--- /dev/null
+++ b/src/components/Tag.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+
+interface TagProps {
+ tag: string;
+ index: number;
+ className?: string;
+}
+
+const getColorClass = (tag: string) => {
+ const tagLower = tag.toLowerCase();
+ if (tagLower.includes('meta') || tagLower.includes('learning')) return 'highlighter-yellow';
+ if (tagLower.includes('debug') || tagLower.includes('tools')) return 'highlighter-pink';
+ if (tagLower.includes('ai') || tagLower.includes('automation')) return 'highlighter-blue';
+ if (tagLower.includes('script') || tagLower.includes('code')) return 'highlighter-green';
+ return 'highlighter-yellow'; // default
+};
+
+export const Tag: React.FC = ({ tag, index, className = '' }) => {
+ const colorClass = getColorClass(tag);
+
+ return (
+
+ {tag}
+
+ );
+};
diff --git a/src/components/TwitterEmbed.astro b/src/components/TwitterEmbed.astro
deleted file mode 100644
index 6f9edbd..0000000
--- a/src/components/TwitterEmbed.astro
+++ /dev/null
@@ -1,65 +0,0 @@
----
-// TwitterEmbed.astro - Build-time Twitter embed using oEmbed API
-interface Props {
- tweetId: string;
- theme?: 'light' | 'dark';
- className?: string;
- align?: 'left' | 'center' | 'right';
-}
-
-const {
- tweetId,
- theme = 'light',
- className = "",
- align = 'center'
-} = Astro.props;
-
-// Fetch tweet data at build time using Twitter oEmbed API
-let embedHtml = '';
-let fallbackHtml = '';
-
-try {
- const oEmbedUrl = `https://publish.twitter.com/oembed?url=https://twitter.com/twitter/status/${tweetId}&theme=${theme}`;
- const response = await fetch(oEmbedUrl);
-
- if (response.ok) {
- const data = await response.json();
- embedHtml = data.html || '';
- } else {
- console.warn(`Twitter oEmbed failed for tweet ${tweetId}: ${response.status}`);
- }
-} catch (error) {
- console.warn(`Failed to fetch Twitter embed for ${tweetId}:`, error);
-}
-
-// Fallback HTML if oEmbed fails
-if (!embedHtml) {
- fallbackHtml = `
-
-
-
-
-
Unable to load tweet
-
- View on Twitter →
-
-
- `;
-}
----
-
-
diff --git a/src/components/TwitterEmbed.tsx b/src/components/TwitterEmbed.tsx
new file mode 100644
index 0000000..8dd9923
--- /dev/null
+++ b/src/components/TwitterEmbed.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+interface TwitterEmbedProps {
+ tweetId: string;
+ theme?: 'light' | 'dark';
+ className?: string;
+ align?: 'left' | 'center' | 'right';
+}
+
+export async function TwitterEmbed({
+ tweetId,
+ theme = 'light',
+ className = "",
+ align = 'center'
+}: TwitterEmbedProps) {
+ let embedHtml = '';
+
+ try {
+ const oEmbedUrl = `https://publish.twitter.com/oembed?url=https://twitter.com/twitter/status/${tweetId}&theme=${theme}`;
+ const response = await fetch(oEmbedUrl);
+
+ if (response.ok) {
+ const data = await response.json();
+ embedHtml = data.html || '';
+ } else {
+ console.warn(`Twitter oEmbed failed for tweet ${tweetId}: ${response.status}`);
+ }
+ } catch (error) {
+ console.warn(`Failed to fetch Twitter embed for ${tweetId}:`, error);
+ }
+
+ const alignmentClass = align === 'left' ? 'mr-auto ml-0' : align === 'right' ? 'ml-auto mr-0' : 'mx-auto';
+
+ return (
+
+ {embedHtml ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/YouTubeEmbed.astro b/src/components/YouTubeEmbed.astro
deleted file mode 100644
index bfb6101..0000000
--- a/src/components/YouTubeEmbed.astro
+++ /dev/null
@@ -1,32 +0,0 @@
----
-// YouTubeEmbed.astro - Build-time component with full styling control
-interface Props {
- videoId: string;
- title?: string;
- className?: string;
- aspectRatio?: string;
- style?: 'default' | 'minimal' | 'rounded' | 'flat';
-}
-
-const {
- videoId,
- title = "YouTube Video",
- className = "",
- aspectRatio = "56.25%",
- style = "default"
-} = Astro.props;
-
-const embedUrl = `https://www.youtube.com/embed/${videoId}`;
----
-
-
diff --git a/src/components/YouTubeEmbed.tsx b/src/components/YouTubeEmbed.tsx
new file mode 100644
index 0000000..4ef2171
--- /dev/null
+++ b/src/components/YouTubeEmbed.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+interface YouTubeEmbedProps {
+ videoId: string;
+ title?: string;
+ className?: string;
+ aspectRatio?: string;
+ style?: 'default' | 'minimal' | 'rounded' | 'flat';
+}
+
+export const YouTubeEmbed: React.FC = ({
+ videoId,
+ title = "YouTube Video",
+ className = "",
+ aspectRatio = "56.25%",
+ style = "default"
+}) => {
+ const embedUrl = `https://www.youtube.com/embed/${videoId}`;
+
+ return (
+
+ );
+};
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
deleted file mode 100644
index 195d4e0..0000000
--- a/src/layouts/BaseLayout.astro
+++ /dev/null
@@ -1,227 +0,0 @@
----
-import '../styles/global.css';
-import { Footer } from '../components/Footer';
-import { Hero } from '../components/Hero';
-import Analytics from '../components/Analytics.astro';
-
-// Import Prism.js components for syntax highlighting
-import 'prismjs/components/prism-python';
-import 'prismjs/components/prism-typescript';
-import 'prismjs/components/prism-javascript';
-import 'prismjs/components/prism-jsx';
-import 'prismjs/components/prism-tsx';
-import 'prismjs/components/prism-docker';
-import 'prismjs/components/prism-yaml';
-import 'prismjs/components/prism-json';
-import 'prismjs/components/prism-markup';
-import 'prismjs/components/prism-css';
-import 'prismjs/components/prism-sql';
-import 'prismjs/components/prism-bash';
-import 'prismjs/components/prism-markdown';
-
-interface Props {
- title: string;
- description?: string;
- image?: string;
- keywords?: string[];
- canonicalUrl?: string;
-}
-
-const { title, description = "Technical problem solver's blog - practical insights and learning notes", image, keywords, canonicalUrl } = Astro.props;
-const siteUrl = 'https://mintel.me';
-const currentUrl = canonicalUrl || (new URL(Astro.request.url)).pathname;
-const fullUrl = `${siteUrl}${currentUrl}`;
-const slug = currentUrl.split('/').filter(Boolean).pop() || 'home';
-const ogImage = image || `${siteUrl}/api/og/${slug}.svg`;
-const twitterImage = image || `${siteUrl}/api/og/${slug}.svg`;
-const keywordsString = keywords ? keywords.join(', ') : '';
----
-
-
-
-
-
-
- {title} | Marc Mintel
-
- {keywordsString && }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/pages/api/og/[...slug].svg.ts b/src/pages/api/og/[...slug].svg.ts
deleted file mode 100644
index aab2f00..0000000
--- a/src/pages/api/og/[...slug].svg.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { APIRoute } from 'astro';
-import { blogPosts } from '../../../data/blogPosts';
-
-export async function getStaticPaths() {
- const paths = blogPosts.map(post => ({
- params: { slug: post.slug }
- }));
-
- // Add home page
- paths.push({ params: { slug: 'home' } });
-
- return paths;
-}
-
-export const GET: APIRoute = async ({ params }) => {
- const slug = params.slug;
-
- let title: string;
- let description: string;
-
- // Handle home page
- if (slug === 'home') {
- title = 'Marc Mintel';
- description = 'Technical problem solver\'s blog - practical insights and learning notes';
- } else {
- // Find the blog post
- const post = blogPosts.find(p => p.slug === slug);
-
- // Default content if no post found
- title = (post?.title || 'Marc Mintel').replace(/[<>&'"]/g, '');
- description = (post?.description || 'Technical problem solver\'s blog - practical insights and learning notes').slice(0, 100).replace(/[<>&'"]/g, '');
- }
-
- // Create SVG with typographic design matching the site
- const svg = `
-
-
-
-
-
-
-
-
-
- ${title}
-
-
-
-
- ${description}
-
-
-
-
- mintel.me
-
-
-
-
- `;
-
- return new Response(svg, {
- headers: {
- 'Content-Type': 'image/svg+xml',
- 'Cache-Control': 'public, max-age=31536000, immutable',
- },
- });
-};
\ No newline at end of file
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro
deleted file mode 100644
index ea73789..0000000
--- a/src/pages/blog/[slug].astro
+++ /dev/null
@@ -1,754 +0,0 @@
----
-import BaseLayout from '../../layouts/BaseLayout.astro';
-import Tag from '../../components/Tag.astro';
-import { blogPosts } from '../../data/blogPosts';
-import { CodeBlock, InlineCode } from '../../components/ArticleBlockquote';
-import { H2, H3 } from '../../components/ArticleHeading';
-import { Paragraph, LeadParagraph } from '../../components/ArticleParagraph';
-import { UL, LI } from '../../components/ArticleList';
-import FileExamplesList from '../../components/FileExamplesList.astro';
-
-export async function getStaticPaths() {
- return blogPosts.map(post => ({
- params: { slug: post.slug },
- props: { post }
- }));
-}
-
-const { post } = Astro.props;
-
-const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric'
-});
-
-// Calculate reading time
-const wordCount = post.description.split(/\s+/).length + 100;
-const readingTime = Math.max(1, Math.ceil(wordCount / 200));
-
-// Determine if this post should show file examples
-const showFileExamples = post.tags?.some(tag =>
- ['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
-);
----
-
-
-
-
-
-
-
-
-
- Back
-
-
-
-
- {readingTime} min read
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {post.title}
-
-
-
-
-
-
-
-
- {formattedDate}
-
-
•
-
-
-
-
- {readingTime} min
-
-
-
-
-
- {post.description}
-
-
-
- {post.tags && post.tags.length > 0 && (
-
- {post.tags.map((tag: string, index: number) => (
-
- ))}
-
- )}
-
-
-
-
-
-
-
-
{post.description}
-
- {post.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
-
- >
- )}
-
- {post.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.
-
-
-
-
-
-
- >
- )}
-
- {post.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.
-
-
-
-
-
-
- >
- )}
-
- {post.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.
-
-
-
-
-
-
- >
- )}
-
-
-
- {showFileExamples && post.slug !== 'debugging-tips' && (
-
-
Code Examples
-
- Below you'll find complete file examples related to this topic. You can copy individual files or download them all as a zip.
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
- Back to all posts
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/pages/blog/embed-demo.astro b/src/pages/blog/embed-demo.astro
deleted file mode 100644
index a848fe1..0000000
--- a/src/pages/blog/embed-demo.astro
+++ /dev/null
@@ -1,512 +0,0 @@
----
-// Demo blog post showing embed components in action
-import BaseLayout from '../../layouts/BaseLayout.astro';
-import Tag from '../../components/Tag.astro';
-import { H2, H3 } from '../../components/ArticleHeading';
-import { Paragraph, LeadParagraph } from '../../components/ArticleParagraph';
-import { UL, LI } from '../../components/ArticleList';
-import { CodeBlock } from '../../components/ArticleBlockquote';
-
-// Import embed components
-import YouTubeEmbed from '../../components/YouTubeEmbed.astro';
-import TwitterEmbed from '../../components/TwitterEmbed.astro';
-import GenericEmbed from '../../components/GenericEmbed.astro';
-import Mermaid from '../../components/Mermaid.astro';
-
-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;
----
-
-
-
-
-
-
-
-
-
- Back
-
-
-
-
- {readingTime} min read
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {post.title}
-
-
-
-
-
-
-
- {formattedDate}
-
-
•
-
-
-
-
- {readingTime} min
-
-
-
-
- {post.description}
-
-
-
- {post.tags.map((tag: string, index: number) => (
-
- ))}
-
-
-
-
-
-
-
-
- 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:
-
-
-
-
- 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:
-
-
-
- `}
- />
-
-
-
-
-
-
-
-
- Back to all posts
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/pages/index.astro b/src/pages/index.astro
deleted file mode 100644
index 9929b0e..0000000
--- a/src/pages/index.astro
+++ /dev/null
@@ -1,533 +0,0 @@
----
-import BaseLayout from '../layouts/BaseLayout.astro';
-import MediumCard from '../components/MediumCard.astro';
-import { SearchBar } from '../components/SearchBar';
-import Tag from '../components/Tag.astro';
-import { blogPosts } from '../data/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 = [...new Set(allPosts.flatMap(post => post.tags))];
----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Marc Mintel
-
-
- "A public notebook of things I figured out, mistakes I made, and tools I tested."
-
-
-
-
- Vulkaneifel, Germany
-
-
•
-
Digital problem solver
-
-
-
-
-
-
-
-
-
- {allTags.length > 0 && (
-
- Topics
-
- {allTags.map((tag, index) => (
-
-
-
- ))}
-
-
- )}
-
-
-
-
- {allPosts.length === 0 ? (
-
-
No posts yet. Check back soon!
-
- ) : (
- allPosts.map(post => (
-
-
-
- ))
- )}
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro
deleted file mode 100644
index e6a3904..0000000
--- a/src/pages/tags/[tag].astro
+++ /dev/null
@@ -1,42 +0,0 @@
----
-import BaseLayout from '../../layouts/BaseLayout.astro';
-import { blogPosts } from '../../data/blogPosts';
-import MediumCard from '../../components/MediumCard.astro';
-
-export async function getStaticPaths() {
- const allTags = [...new Set(blogPosts.flatMap(post => post.tags))];
-
- return allTags.map(tag => ({
- params: { tag },
- props: { tag }
- }));
-}
-
-const { tag } = Astro.props;
-const posts = blogPosts.filter(post => post.tags.includes(tag));
----
-
-
-
-
-
-
- {posts.map(post => (
-
- ))}
-
-
-
-
-
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
index 98e4a32..bbd3d89 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,7 +1,10 @@
+import typography from '@tailwindcss/typography';
+
/** @type {import('tailwindcss').Config} */
-module.exports = {
+export default {
content: [
- './src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
+ './app/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
@@ -63,6 +66,6 @@ module.exports = {
},
},
plugins: [
- require('@tailwindcss/typography'),
+ typography,
],
-}
\ No newline at end of file
+}
diff --git a/tsconfig.json b/tsconfig.json
index 69c1600..f41c3a4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,14 +1,35 @@
{
- "extends": "astro/tsconfigs/strict",
- "include": [
- ".astro/types.d.ts",
- "**/*"
- ],
- "exclude": [
- "dist"
- ],
"compilerOptions": {
+ "target": "ESNext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
"jsx": "react-jsx",
- "jsxImportSource": "react"
- }
-}
\ No newline at end of file
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules", "scripts"]
+}