migrate to nextjs

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

18
.gitignore vendored
View File

@@ -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

View File

@@ -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';
}
}

View File

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

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

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

View File

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

695
app/globals.css Normal file
View File

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

40
app/layout.tsx Normal file
View File

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

191
app/page.tsx Normal file
View File

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

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

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

View File

@@ -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'
}
});

6
next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

257
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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?.() || '';
---
<!-- Analytics Script -->
{scriptTag && <Fragment set:html={scriptTag} />}
<!-- Analytics Event Tracking -->
<script>
// Initialize analytics service in browser
import { createPlausibleAnalytics } from '../utils/analytics';
const analytics = createPlausibleAnalytics({
domain: document.documentElement.lang || 'mintel.me',
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
});
// 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) {
searchInput.addEventListener('search', (e) => {
const target = e.target as HTMLInputElement;
if (target.value) {
analytics.trackSearch(target.value, window.location.pathname);
}
});
}
};
// Initialize all tracking
document.addEventListener('DOMContentLoaded', () => {
trackPageLoad();
trackOutboundLinks();
trackSearch();
});
</script>

View File

@@ -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<AnalyticsProps> = ({
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;
};

View File

@@ -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<BlogPostClientProps> = ({ 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 (
<>
<nav
id="top-nav"
className={`fixed top-0 left-0 right-0 z-40 border-b border-slate-200 transition-all duration-300 ${
isScrolled ? 'bg-white/95 backdrop-blur-md' : 'bg-white/80 backdrop-blur-sm'
}`}
>
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<button
onClick={handleBack}
className="flex items-center gap-2 text-slate-700 hover:text-blue-600 transition-colors duration-200 group"
aria-label="Back to home"
>
<svg className="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span className="font-medium">Back</span>
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500 font-sans hidden sm:inline">
{readingTime} min read
</span>
<button
onClick={handleShare}
className="share-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-blue-50 border border-slate-200 hover:border-blue-300 rounded-full transition-all duration-200"
aria-label="Share this post"
>
<svg className="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
</button>
</div>
</div>
</nav>
<div className="mt-16 pt-8 border-t border-slate-200 text-center">
<button
onClick={handleBack}
className="inline-flex items-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-full hover:border-blue-400 hover:text-blue-600 hover:shadow-md transition-all duration-300 font-medium group"
>
<svg className="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</button>
</div>
</>
);
};

View File

@@ -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';
}
}

View File

@@ -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<string, string> = {
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,
);
---
<div
class="file-example w-full bg-white border border-slate-200/80 rounded-lg overflow-hidden"
data-file-example
data-expanded="false"
>
<div
class="px-3 py-2 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50/60 transition-colors"
data-file-header
role="button"
tabindex="0"
aria-expanded="false"
aria-controls={contentId}
id={headerId}
>
<div class="flex items-center gap-2 min-w-0">
<svg
class="w-3 h-3 text-slate-400 flex-shrink-0 toggle-icon transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<span class="text-xs font-mono text-slate-900 truncate" title={filename}>{filename}</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation()">
<button
type="button"
class="copy-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors"
data-content={content}
data-filename={filename}
title="Copy to clipboard"
aria-label={`Copy ${filename} to clipboard`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button
type="button"
class="download-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors"
data-content={content}
data-filename={filename}
title="Download file"
aria-label={`Download ${filename}`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
</div>
<div
class="file-example__content max-h-0 opacity-0 overflow-hidden transition-[max-height,opacity] duration-200 ease-out bg-slate-50"
data-file-content
id={contentId}
role="region"
aria-labelledby={headerId}
>
<pre
class="m-0 p-3 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border border-slate-200 rounded"
style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; max-height: 22rem;"
><code class={`language-${prismLanguage}`} set:html={highlightedCode}></code></pre>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-file-example]').forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const header = node.querySelector('[data-file-header]');
const content = node.querySelector('[data-file-content]');
if (!(header instanceof HTMLElement) || !(content instanceof HTMLElement)) return;
const collapse = () => {
node.dataset.expanded = 'false';
header.setAttribute('aria-expanded', 'false');
content.classList.add('max-h-0', 'opacity-0');
content.classList.remove('max-h-[22rem]', 'opacity-100');
};
const expand = () => {
node.dataset.expanded = 'true';
header.setAttribute('aria-expanded', 'true');
content.classList.remove('max-h-0', 'opacity-0');
content.classList.add('max-h-[22rem]', 'opacity-100');
};
collapse();
const toggle = () => {
const isExpanded = node.dataset.expanded === 'true';
if (isExpanded) collapse();
else expand();
if (!isExpanded) {
setTimeout(() => {
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 120);
}
};
header.addEventListener('click', toggle);
header.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
toggle();
});
});
document.querySelectorAll('.copy-btn').forEach((btn) => {
if (!(btn instanceof HTMLButtonElement)) return;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = btn.dataset.content || '';
try {
await navigator.clipboard.writeText(text);
btn.dataset.copied = 'true';
setTimeout(() => {
delete btn.dataset.copied;
}, 900);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard');
}
});
});
document.querySelectorAll('.download-btn').forEach((btn) => {
if (!(btn instanceof HTMLButtonElement)) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const fileContent = btn.dataset.content || '';
const fileName = btn.dataset.filename || 'download.txt';
const blob = new Blob([fileContent], { 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);
});
});
});
</script>
<style is:global>
[data-file-example] {
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
[data-file-example][data-expanded='true'] .toggle-icon {
transform: rotate(0deg);
}
.toggle-icon {
transform: rotate(-90deg);
}
.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;
}
</style>

View File

@@ -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<string, string> = {
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<FileExampleProps> = ({
filename,
content,
language,
id
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const contentRef = useRef<HTMLDivElement>(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 (
<div
className="file-example w-full bg-white border border-slate-200/80 rounded-lg overflow-hidden"
data-file-example
data-expanded={isExpanded}
>
<div
className="px-3 py-2 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50/60 transition-colors"
onClick={toggleExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand();
}
}}
aria-expanded={isExpanded}
aria-controls={contentId}
id={headerId}
>
<div className="flex items-center gap-2 min-w-0">
<svg
className={`w-3 h-3 text-slate-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
<span className="text-xs font-mono text-slate-900 truncate" title={filename}>{filename}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
className={`copy-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors ${isCopied ? 'copied' : ''}`}
onClick={handleCopy}
title="Copy to clipboard"
aria-label={`Copy ${filename} to clipboard`}
data-copied={isCopied}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button
type="button"
className="download-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors"
onClick={handleDownload}
title="Download file"
aria-label={`Download ${filename}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
</div>
<div
ref={contentRef}
className={`file-example__content overflow-hidden transition-[max-height,opacity] duration-200 ease-out bg-slate-50 ${isExpanded ? 'max-h-[22rem] opacity-100' : 'max-h-0 opacity-0'}`}
id={contentId}
role="region"
aria-labelledby={headerId}
>
<pre
className="m-0 p-3 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border border-slate-200 rounded"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", maxHeight: "22rem" }}
>
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }}></code>
</pre>
</div>
</div>
);
};

View File

@@ -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 ? (
<div class="bg-slate-50 border border-slate-200 rounded-lg px-4 py-6 text-center">
<svg class="w-6 h-6 mx-auto text-slate-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-slate-500 text-sm">No files found</p>
</div>
) : (
<div class="space-y-3">
{groups.map((group) => (
<section
class="not-prose bg-white border border-slate-200/80 rounded-lg w-full overflow-hidden"
data-file-examples-group
>
<header class="px-3 py-2 grid grid-cols-[1fr_auto] items-center gap-3 border-b border-slate-200/80 bg-white">
<h3 class="m-0 text-xs font-semibold text-slate-900 truncate tracking-tight leading-none">{group.title}</h3>
<div class="flex items-center gap-2">
<span class="text-[11px] text-slate-600 bg-slate-100/80 border border-slate-200/60 rounded-full px-2 py-0.5 tabular-nums">
{group.files.length} files
</span>
<button
type="button"
class="toggle-all-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white text-slate-500 hover:text-slate-900 transition-colors"
title="Toggle all"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</header>
<div class="px-2 py-2 space-y-2">
{group.files.map((file) => (
<FileExample
filename={file.filename}
content={file.content}
language={file.language}
description={file.description}
tags={file.tags}
id={file.id}
/>
))}
</div>
</section>
))}
</div>
)}
<script>
// @ts-nocheck
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-file-examples-group]').forEach((groupNode) => {
if (!(groupNode instanceof HTMLElement)) return;
const toggleBtn = groupNode.querySelector('.toggle-all-btn');
if (!(toggleBtn instanceof HTMLButtonElement)) return;
const setIcon = () => {
const expanded = anyExpanded();
toggleBtn.innerHTML = expanded
? '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>'
: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>';
};
const getItems = () => {
const examples = groupNode.querySelectorAll('[data-file-example]');
return Array.from(examples).filter((n) => n instanceof HTMLElement);
};
const anyExpanded = () => getItems().some((n) => n.dataset.expanded === 'true');
setIcon();
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const items = getItems();
const shouldCollapse = anyExpanded();
items.forEach((fileExample) => {
const header = fileExample.querySelector('[data-file-header]');
if (!(header instanceof HTMLElement)) return;
const isExpanded = fileExample.dataset.expanded === 'true';
if (shouldCollapse ? isExpanded : !isExpanded) header.click();
});
setTimeout(() => {
setIcon();
}, 0);
});
});
});
</script>

View File

@@ -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<FileExamplesListProps> = ({ groups }) => {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
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 (
<div className="bg-slate-50 border border-slate-200 rounded-lg px-4 py-6 text-center">
<svg className="w-6 h-6 mx-auto text-slate-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500 text-sm">No files found</p>
</div>
);
}
return (
<div className="space-y-3">
{groups.map((group) => (
<section
key={group.groupId}
className="not-prose bg-white border border-slate-200/80 rounded-lg w-full overflow-hidden"
data-file-examples-group
>
<header className="px-3 py-2 grid grid-cols-[1fr_auto] items-center gap-3 border-b border-slate-200/80 bg-white">
<h3 className="m-0 text-xs font-semibold text-slate-900 truncate tracking-tight leading-none">{group.title}</h3>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-600 bg-slate-100/80 border border-slate-200/60 rounded-full px-2 py-0.5 tabular-nums">
{group.files.length} files
</span>
<button
type="button"
className="toggle-all-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white text-slate-500 hover:text-slate-900 transition-colors"
title="Toggle all"
onClick={() => toggleAllInGroup(group.groupId, group.files)}
>
{group.files.some(f => expandedGroups[f.id]) ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 15l7-7 7 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
</div>
</header>
<div className="px-2 py-2 space-y-2">
{group.files.map((file) => (
<FileExample
key={file.id}
filename={file.filename}
content={file.content}
language={file.language}
description={file.description}
tags={file.tags}
id={file.id}
/>
))}
</div>
</section>
))}
</div>
);
};

View File

@@ -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;
---
<div
class={`generic-embed not-prose ${className}`}
data-provider={provider}
data-type={type}
style={`--max-width: ${maxWidth};`}
>
{hasEmbed ? (
<div class="embed-wrapper">
{type === 'video' ? (
<iframe
src={embedUrl}
width="100%"
height="100%"
style="border: none; width: 100%; height: 100%;"
allowfullscreen
loading="lazy"
/>
) : (
<iframe
src={embedUrl}
width="100%"
height="400"
style="border: none;"
loading="lazy"
/>
)}
</div>
) : (
<div class="embed-fallback">
<div class="fallback-content">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Unable to embed this URL</span>
<a href={url} target="_blank" rel="noopener noreferrer" class="fallback-link">
Open link →
</a>
</div>
</div>
)}
</div>
<style>
.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;
}
/* Video type gets aspect ratio */
.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;
}
/* Provider-specific styling */
.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;
}
/* Fallback styling */
.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;
}
/* Responsive */
@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);
}
}
</style>

View File

@@ -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<GenericEmbedProps> = ({
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 (
<div
className={`generic-embed not-prose ${className}`}
data-provider={provider}
data-type={type}
style={{ '--max-width': maxWidth } as React.CSSProperties}
>
{hasEmbed ? (
<div className="embed-wrapper">
{type === 'video' ? (
<iframe
src={embedUrl!}
width="100%"
height="100%"
style={{ border: 'none', width: '100%', height: '100%' }}
allowFullScreen
loading="lazy"
title={`${provider} embed`}
/>
) : (
<iframe
src={embedUrl!}
width="100%"
height="400"
style={{ border: 'none' }}
loading="lazy"
title={`${provider} embed`}
/>
)}
</div>
) : (
<div className="embed-fallback">
<div className="fallback-content">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Unable to embed this URL</span>
<a href={url} target="_blank" rel="noopener noreferrer" className="fallback-link">
Open link
</a>
</div>
</div>
)}
</div>
);
};

View File

@@ -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 */}
<div
className="reading-progress-bar"
style={{
transform: `scaleX(${progress / 100})`,
display: 'block'
}}
/>
{/* Floating Back to Top Button */}
<button
onClick={scrollToTop}
className={`floating-back-to-top ${showBackToTop ? 'visible' : ''}`}
aria-label="Back to top"
style={{ display: 'flex' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
</>
);
};

View File

@@ -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));
---
<article class="post-card bg-white border border-slate-200/80 rounded-lg px-4 py-3">
<div class="flex items-start justify-between gap-4">
<h3 class="m-0 text-sm font-semibold leading-snug tracking-tight">
<span class="relative inline-block text-slate-900 marker-title" style={`--marker-seed:${Math.abs(title.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)) % 7};`}>
{title}
</span>
</h3>
<time class="text-[11px] text-slate-500 tabular-nums whitespace-nowrap leading-none pt-0.5">
{formattedDate}
</time>
</div>
<p class="post-excerpt mt-2 mb-0 text-[13px] leading-relaxed text-slate-600 line-clamp-3">
{description}
</p>
<div class="mt-3 flex items-center justify-between gap-3">
<span class="text-[11px] text-slate-500 tabular-nums leading-none">{readingTime} min</span>
{tags.length > 0 && (
<div class="flex flex-wrap items-center justify-end gap-1">
{tags.slice(0, 3).map((tag: string) => (
<span class="inline-flex items-center rounded-full bg-slate-100/80 border border-slate-200/60 px-2 py-0.5 text-[11px] text-slate-700 leading-none">
{tag}
</span>
))}
</div>
)}
</div>
</article>
<style is:global>
.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);
}
}
</style>

View File

@@ -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<MediumCardProps> = ({ 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 (
<Link href={`/blog/${slug}`} className="post-link block group">
<article className="post-card bg-white border border-slate-200/80 rounded-lg px-4 py-3 transition-all duration-200 group-hover:border-slate-300 group-hover:shadow-sm">
<div className="flex items-start justify-between gap-4">
<h3 className="m-0 text-sm font-semibold leading-snug tracking-tight">
<span
className="relative inline-block text-slate-900 marker-title"
style={{ '--marker-seed': markerSeed } as React.CSSProperties}
>
{title}
</span>
</h3>
<time className="text-[11px] text-slate-500 tabular-nums whitespace-nowrap leading-none pt-0.5">
{formattedDate}
</time>
</div>
<p className="post-excerpt mt-2 mb-0 text-[13px] leading-relaxed text-slate-600 line-clamp-3">
{description}
</p>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[11px] text-slate-500 tabular-nums leading-none">{readingTime} min</span>
{tags.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-1">
{tags.slice(0, 3).map((tag: string) => (
<span key={tag} className="inline-flex items-center rounded-full bg-slate-100/80 border border-slate-200/60 px-2 py-0.5 text-[11px] text-slate-700 leading-none">
{tag}
</span>
))}
</div>
)}
</div>
</article>
</Link>
);
};

View File

@@ -1,128 +0,0 @@
---
interface Props {
graph: string;
id?: string;
}
const { graph, id = `mermaid-${Math.random().toString(36).substring(2, 11)}` } = Astro.props;
---
<div class="mermaid-wrapper my-8 not-prose">
<div class="bg-white border border-slate-200/80 rounded-lg overflow-hidden shadow-sm">
<div class="px-3 py-2 border-b border-slate-100 bg-slate-50/30 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="text-xs font-medium text-slate-500 uppercase tracking-wider">Diagram</span>
</div>
</div>
<div
class="mermaid-container p-8 md:p-12 overflow-x-auto flex justify-center bg-white"
>
<div class="mermaid opacity-0 transition-opacity duration-500 w-full max-w-4xl" id={id}>
{graph}
</div>
</div>
</div>
</div>
<script>
import mermaid from 'mermaid';
// Initialize mermaid
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 renderMermaid = async () => {
const elements = document.querySelectorAll('.mermaid');
for (const element of elements) {
if (element.getAttribute('data-processed')) continue;
try {
const content = element.textContent || '';
const id = element.id;
const { svg } = await mermaid.render(`${id}-svg`, content);
element.innerHTML = svg;
element.classList.remove('opacity-0');
element.setAttribute('data-processed', 'true');
} catch (error) {
console.error('Mermaid rendering failed:', error);
element.innerHTML = '<div class="text-red-500 p-4 border border-red-200 rounded bg-red-50 text-sm">Failed to render diagram. Please check the syntax.</div>';
element.classList.remove('opacity-0');
}
}
};
// Run on initial load
renderMermaid();
// Support for Astro View Transitions if added later
document.addEventListener('astro:page-load', renderMermaid);
</script>
<style>
.mermaid-container :global(svg) {
width: 100% !important;
max-width: 100%;
height: auto;
display: block;
background-color: transparent !important;
}
.mermaid-container :global(rect),
.mermaid-container :global(circle),
.mermaid-container :global(ellipse),
.mermaid-container :global(polygon),
.mermaid-container :global(path),
.mermaid-container :global(.actor),
.mermaid-container :global(.node) {
fill: white !important;
stroke: #cbd5e1 !important;
stroke-width: 1.5px !important;
}
.mermaid-container :global(.edgePath .path),
.mermaid-container :global(.messageLine0),
.mermaid-container :global(.messageLine1),
.mermaid-container :global(.flowchart-link) {
stroke: #cbd5e1 !important;
stroke-width: 1.5px !important;
}
.mermaid-container :global(text),
.mermaid-container :global(.label),
.mermaid-container :global(.labelText),
.mermaid-container :global(.edgeLabel),
.mermaid-container :global(.node text),
.mermaid-container :global(tspan) {
font-family: 'Inter', sans-serif !important;
fill: #334155 !important;
color: #334155 !important;
stroke: none !important;
font-size: 16px !important;
}
.mermaid-container :global(.marker),
.mermaid-container :global(marker path) {
fill: #cbd5e1 !important;
stroke: #cbd5e1 !important;
}
/* Hide the raw text before rendering */
.mermaid:not([data-processed]) {
visibility: hidden;
}
</style>

View File

@@ -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<MermaidProps> = ({ graph, id: providedId }) => {
const [id, setId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setId(providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`);
}, [providedId]);
const [isRendered, setIsRendered] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="mermaid-wrapper my-8 not-prose">
<div className="bg-white border border-slate-200/80 rounded-lg overflow-hidden shadow-sm">
<div className="px-3 py-2 border-b border-slate-100 bg-slate-50/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">Diagram</span>
</div>
</div>
<div className="mermaid-container p-8 md:p-12 overflow-x-auto flex justify-center bg-white">
<div
ref={containerRef}
className={`mermaid transition-opacity duration-500 w-full max-w-4xl ${isRendered ? 'opacity-100' : 'opacity-0'}`}
id={id}
>
{error ? (
<div className="text-red-500 p-4 border border-red-200 rounded bg-red-50 text-sm">
{error}
</div>
) : (
graph
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -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<SearchBarProps> = ({ value: propValue, onChange }) => {
const [internalValue, setInternalValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const value = propValue !== undefined ? propValue : internalValue;
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLElement>;
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<HTMLElement>;
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 && (
<button
onClick={clearSearch}
className="absolute right-1.5 top-1/2 -translate-y-1/2 h-7 px-2 inline-flex items-center justify-center rounded text-[12px] text-slate-500 hover:text-slate-700 hover:bg-slate-100 transition-colors"
@@ -102,4 +77,4 @@ export const SearchBar: React.FC = () => {
</div>
</div>
);
};
};

View File

@@ -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);
---
<span class={`highlighter-tag ${colorClass} ${className} inline-block text-xs font-bold px-2.5 py-1 rounded cursor-pointer transition-all duration-200 relative overflow-hidden group`} data-tag={tag}>
<span class="relative z-10">{tag}</span>
</span>
<style>
/* Enhanced hover effects */
.highlighter-tag {
transform: rotate(-1deg) translateY(0);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
}
.highlighter-tag:hover {
transform: rotate(-2deg) translateY(-2px) scale(1.05);
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.15);
}
/* Staggered entrance animation */
@keyframes tagPopIn {
from {
opacity: 0;
transform: rotate(-1deg) scale(0.8) translateY(5px);
}
to {
opacity: 1;
transform: rotate(-1deg) scale(1) translateY(0);
}
}
.highlighter-tag {
animation: tagPopIn 0.3s ease-out both;
animation-delay: calc(var(--tag-index, 0) * 0.05s);
}
/* Color variations with gradients */
.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;
}
/* Hover glow effect */
.highlighter-tag:hover::before {
content: '';
position: absolute;
inset: -2px;
background: inherit;
filter: blur(8px);
opacity: 0.4;
z-index: -1;
border-radius: inherit;
}
/* Click effect */
.highlighter-tag:active {
transform: rotate(-1deg) translateY(0) scale(0.98);
transition: transform 0.1s ease;
}
/* Focus effect for accessibility */
.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);
}
</style>
<script>
// Add click handler for tag filtering
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
const tags = document.querySelectorAll('.highlighter-tag');
tags.forEach(tagElement => {
tagElement.addEventListener('click', (e) => {
const tag = tagElement.getAttribute('data-tag');
if (tag && typeof (window as any).filterByTag === 'function') {
(window as any).filterByTag(tag);
}
});
});
});
}
</script>

33
src/components/Tag.tsx Normal file
View File

@@ -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<TagProps> = ({ tag, index, className = '' }) => {
const colorClass = getColorClass(tag);
return (
<Link
href={`/tags/${tag}`}
className={`highlighter-tag ${colorClass} ${className} inline-block text-xs font-bold px-2.5 py-1 rounded cursor-pointer transition-all duration-200 relative overflow-hidden group`}
style={{ '--tag-index': index } as React.CSSProperties}
>
<span className="relative z-10">{tag}</span>
</Link>
);
};

View File

@@ -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 = `
<div class="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href="https://twitter.com/i/status/${tweetId}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 font-medium text-sm">
View on Twitter →
</a>
</div>
`;
}
---
<div class={`not-prose ${className} ${align === 'left' ? 'mr-auto ml-0' : align === 'right' ? 'ml-auto mr-0' : 'mx-auto'} w-4/5`} data-theme={theme} data-align={align}>
{embedHtml ? (
<div set:html={embedHtml} />
) : (
<div class="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href="https://twitter.com/i/status/20" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 font-medium text-sm">
View on Twitter →
</a>
</div>
)}
</div>

View File

@@ -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 (
<div className={`not-prose ${className} ${alignmentClass} w-4/5`} data-theme={theme} data-align={align}>
{embedHtml ? (
<div dangerouslySetInnerHTML={{ __html: embedHtml }} />
) : (
<div className="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href={`https://twitter.com/i/status/${tweetId}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700 font-medium text-sm">
View on Twitter
</a>
</div>
)}
</div>
);
}

View File

@@ -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}`;
---
<div class={`not-prose my-6 ${className}`} data-style={style}>
<div class="bg-white border border-slate-200/80 rounded-xl overflow-hidden transition-all duration-200 hover:border-slate-300/80 p-1" style={`padding-bottom: calc(${aspectRatio} - 0.5rem); position: relative; height: 0; margin-top: 0.25rem; margin-bottom: 0.25rem;`}>
<iframe
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="absolute top-1 left-1 w-[calc(100%-0.5rem)] h-[calc(100%-0.5rem)] border-none rounded-md"
/>
</div>
</div>

View File

@@ -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<YouTubeEmbedProps> = ({
videoId,
title = "YouTube Video",
className = "",
aspectRatio = "56.25%",
style = "default"
}) => {
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
return (
<div className={`not-prose my-6 ${className}`} data-style={style}>
<div
className="bg-white border border-slate-200/80 rounded-xl overflow-hidden transition-all duration-200 hover:border-slate-300/80 p-1"
style={{
paddingBottom: `calc(${aspectRatio} - 0.5rem)`,
position: 'relative',
height: 0,
marginTop: '0.25rem',
marginBottom: '0.25rem'
}}
>
<iframe
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute top-1 left-1 w-[calc(100%-0.5rem)] h-[calc(100%-0.5rem)] border-none rounded-md"
/>
</div>
</div>
);
};

View File

@@ -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(', ') : '';
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} | Marc Mintel</title>
<meta name="description" content={description} />
{keywordsString && <meta name="keywords" content={keywordsString} />}
<meta name="generator" content={Astro.generator} />
<link rel="canonical" href={fullUrl} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={fullUrl} />
<meta property="og:title" content={`${title} | Marc Mintel`} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content="Marc Mintel" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={fullUrl} />
<meta property="twitter:title" content={`${title} | Marc Mintel`} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={twitterImage} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<!-- Single page container -->
<div class="min-h-screen bg-white">
<!-- Main Content -->
<main class="container">
<slot />
</main>
<!-- Footer -->
<Footer client:load />
<!-- Global Interactive Elements -->
<!-- Reading Progress Bar -->
<div id="global-reading-progress" class="reading-progress-bar" style="display: none;"></div>
<!-- Floating Back to Top Button -->
<button
id="global-back-to-top"
class="floating-back-to-top"
aria-label="Back to top"
style="display: none;"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
</div>
<!-- Analytics Component -->
<Analytics />
<!-- Global JavaScript for interactive elements -->
<script>
// Global interactive elements manager
class GlobalInteractive {
readingProgress: HTMLElement | null;
backToTop: HTMLElement | null;
constructor() {
this.readingProgress = document.getElementById('global-reading-progress');
this.backToTop = document.getElementById('global-back-to-top');
this.init();
}
init() {
// Set up event listeners
window.addEventListener('scroll', () => this.handleScroll());
// Back to top click
this.backToTop?.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
// Show elements on first interaction
document.addEventListener('click', () => this.showElements(), { once: true });
document.addEventListener('scroll', () => this.showElements(), { once: true });
}
showElements() {
if (this.readingProgress) {
this.readingProgress.style.display = 'block';
}
if (this.backToTop) {
this.backToTop.style.display = 'flex';
}
}
handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
// Update reading progress
if (this.readingProgress && docHeight > 0) {
const progress = (scrollTop / docHeight) * 100;
this.readingProgress.style.transform = `scaleX(${progress / 100})`;
}
// Show/hide back to top button
if (this.backToTop) {
if (scrollTop > 300) {
this.backToTop.classList.add('visible');
} else {
this.backToTop.classList.remove('visible');
}
}
}
}
// Initialize when DOM is ready
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
new GlobalInteractive();
});
}
</script>
<style>
/* Additional global styles */
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* 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;
}
/* 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,
.keyboard-hint,
.reading-progress-bar {
display: none !important;
}
}
</style>
</body>
</html>

View File

@@ -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 = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.title { font-family: system-ui, -apple-system, sans-serif; font-size: 48px; font-weight: 700; fill: #1e293b; letter-spacing: -0.025em; }
.description { font-family: system-ui, -apple-system, sans-serif; font-size: 24px; font-weight: 400; fill: #64748b; }
.branding { font-family: system-ui, -apple-system, sans-serif; font-size: 18px; font-weight: 500; fill: #94a3b8; }
</style>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="#ffffff"/>
<!-- Title -->
<text x="60" y="200" class="title">
${title}
</text>
<!-- Description -->
<text x="60" y="280" class="description">
${description}
</text>
<!-- Site branding -->
<text x="60" y="580" class="branding">
mintel.me
</text>
<!-- Decorative accent -->
<rect x="1000" y="60" width="120" height="4" fill="#3b82f6" rx="2"/>
</svg>`;
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
};

View File

@@ -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)
);
---
<BaseLayout
title={post.title}
description={post.description}
keywords={post.tags}
canonicalUrl={`/blog/${post.slug}`}
/>
<!-- Top navigation bar with back button and clap counter -->
<nav class="fixed top-0 left-0 right-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200 transition-all duration-300" id="top-nav">
<div class="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<button
id="back-btn-top"
class="flex items-center gap-2 text-slate-700 hover:text-blue-600 transition-colors duration-200 group"
aria-label="Back to home"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span class="font-medium">Back</span>
</button>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500 font-sans hidden sm:inline">
{readingTime} min read
</span>
<button
id="share-btn-top"
class="share-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-blue-50 border border-slate-200 hover:border-blue-300 rounded-full transition-all duration-200"
aria-label="Share this post"
>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
</button>
</div>
</div>
</nav>
<!-- Main content with enhanced animations -->
<main id="post-content" class="pt-24">
<!-- Beautiful hero section -->
<section class="py-12 md:py-16">
<div class="max-w-3xl mx-auto px-6">
<div class="text-center">
<!-- Title -->
<h1
id="article-title"
class="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight cursor-default"
>
{post.title}
</h1>
<!-- Elegant meta info -->
<div
id="article-meta"
class="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans"
>
<time datetime={post.date} class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
</svg>
{formattedDate}
</time>
<span class="text-slate-400">•</span>
<span class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
{readingTime} min
</span>
</div>
<!-- Description with elegant styling -->
<p
id="article-description"
class="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto"
>
{post.description}
</p>
<!-- Tags using the Tag component -->
{post.tags && post.tags.length > 0 && (
<div id="article-tags" class="flex flex-wrap justify-center gap-2 mb-8">
{post.tags.map((tag: string, index: number) => (
<Tag tag={tag} index={index} className="text-xs" />
))}
</div>
)}
</div>
</div>
</section>
<!-- Article content with enhanced typography -->
<section class="max-w-3xl mx-auto px-6 pb-24">
<div class="prose prose-slate max-w-none">
<p>{post.description}</p>
{post.slug === 'first-note' && (
<>
<LeadParagraph>
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
</LeadParagraph>
<H2>Why write in public?</H2>
<Paragraph>
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
</Paragraph>
<H2>What to expect</H2>
<UL>
<LI>Short entries, usually under 500 words</LI>
<LI>Practical solutions to specific problems</LI>
<LI>Notes on tools and workflows</LI>
<LI>Mistakes and what I learned</LI>
</UL>
</>
)}
{post.slug === 'debugging-tips' && (
<>
<LeadParagraph>
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
</LeadParagraph>
<H2>Why print statements work</H2>
<Paragraph>
Debuggers are powerful, but they change how your code runs. Print statements don't.
</Paragraph>
<CodeBlock language="python" showLineNumbers={true}>
{`def process_data(data):
print(f"Processing {len(data)} items")
result = expensive_operation(data)
print(f"Operation result: {result}")
return result`}
</CodeBlock>
<H2>Complete examples</H2>
<Paragraph>
Here are some practical file examples you can copy and download. These include proper error handling and logging.
</Paragraph>
<!-- File examples section -->
<div class="my-8">
<FileExamplesList postSlug="debugging-tips" groupId="python-data-processing" />
</div>
</>
)}
{post.slug === 'architecture-patterns' && (
<>
<LeadParagraph>
Good software architecture is about making the right decisions early. Here are some patterns I've found useful in production systems.
</LeadParagraph>
<H2>Repository Pattern</H2>
<Paragraph>
The repository pattern provides a clean separation between your business logic and data access layer. It makes your code more testable and maintainable.
</Paragraph>
<H2>Service Layer</H2>
<Paragraph>
Services orchestrate business logic and coordinate between repositories and domain events. They keep your controllers thin and your business rules organized.
</Paragraph>
<H2>Domain Events</H2>
<Paragraph>
Domain events help you decouple components and react to changes in your system. They're essential for building scalable, event-driven architectures.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These TypeScript examples demonstrate modern architecture patterns for scalable applications. You can copy them directly into your project.
</Paragraph>
<!-- File examples section -->
<div class="my-8">
<FileExamplesList postSlug="architecture-patterns" />
</div>
</>
)}
{post.slug === 'docker-deployment' && (
<>
<LeadParagraph>
Docker has become the standard for containerizing applications. Here's how to set up production-ready deployments that are secure, efficient, and maintainable.
</LeadParagraph>
<H2>Multi-stage builds</H2>
<Paragraph>
Multi-stage builds keep your production images small and secure by separating build and runtime environments. This reduces attack surface and speeds up deployments.
</Paragraph>
<H2>Health checks and monitoring</H2>
<Paragraph>
Proper health checks ensure your containers are running correctly. Combined with restart policies, this gives you resilient, self-healing deployments.
</Paragraph>
<H2>Orchestration with Docker Compose</H2>
<Paragraph>
Docker Compose makes it easy to manage multi-service applications in development and production. Define services, networks, and volumes in a single file.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These Docker configurations are production-ready. Use them as a starting point for your own deployments.
</Paragraph>
<!-- File examples section -->
<div class="my-8">
<FileExamplesList postSlug="docker-deployment" />
</div>
</>
)}
</div>
<!-- File examples for architecture posts -->
{showFileExamples && post.slug !== 'debugging-tips' && (
<div class="prose prose-slate max-w-none mt-12">
<H2>Code Examples</H2>
<Paragraph>
Below you'll find complete file examples related to this topic. You can copy individual files or download them all as a zip.
</Paragraph>
<div class="my-6">
<FileExamplesList postSlug={post.slug} tags={post.tags} />
</div>
</div>
)}
<!-- Footer with elegant back button -->
<div class="mt-16 pt-8 border-t border-slate-200 text-center">
<button
id="back-btn-bottom"
class="inline-flex items-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-full hover:border-blue-400 hover:text-blue-600 hover:shadow-md transition-all duration-300 font-medium group"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</button>
</div>
</section>
</main>
<!-- Structured Data for SEO -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": post.title,
"description": post.description,
"author": {
"@type": "Person",
"name": "Marc Mintel",
"url": "https://mintel.me"
},
"publisher": {
"@type": "Person",
"name": "Marc Mintel",
"url": "https://mintel.me"
},
"datePublished": post.date,
"dateModified": post.date,
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://mintel.me/blog/${post.slug}`
},
"url": `https://mintel.me/blog/${post.slug}`,
"keywords": post.tags.join(", "),
"articleSection": post.tags[0] || "Technology"
})} />
<script>
// Reading progress bar with smooth gradient
function updateReadingProgress() {
const section = document.querySelector('section:last-child') as HTMLElement;
const progressBar = document.getElementById('reading-progress');
if (!section || !progressBar) return;
const sectionTop = section.offsetTop;
const sectionHeight = section.offsetHeight;
const windowHeight = window.innerHeight;
const scrollTop = window.scrollY;
const progress = Math.min(
Math.max((scrollTop - sectionTop + windowHeight * 0.3) / (sectionHeight - windowHeight * 0.3), 0),
1
);
progressBar.style.width = `${progress * 100}%`;
// Update top nav appearance on scroll
const topNav = document.getElementById('top-nav');
if (topNav) {
if (scrollTop > 100) {
topNav.style.backdropFilter = 'blur(12px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
} else {
topNav.style.backdropFilter = 'blur(8px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.8)';
}
}
}
// Back to top button
function updateBackToTop() {
const backToTopBtn = document.getElementById('back-to-top');
if (!backToTopBtn) return;
if (window.scrollY > 300) {
backToTopBtn.style.opacity = '1';
backToTopBtn.style.pointerEvents = 'auto';
} else {
backToTopBtn.style.opacity = '0';
backToTopBtn.style.pointerEvents = 'none';
}
}
// Back to home with lovely transition
function setupBackNavigation() {
const backButtons = [
document.getElementById('back-btn-top'),
document.getElementById('back-btn-bottom')
];
const goHome = () => {
// 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)';
}
// Fade out top nav
const topNav = document.getElementById('top-nav');
if (topNav) {
topNav.style.transition = 'opacity 0.4s ease-out';
topNav.style.opacity = '0';
}
// Create beautiful overlay
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);
// Navigate after animation
setTimeout(() => {
window.location.href = '/?from=post';
}, 500);
};
backButtons.forEach(btn => {
if (btn) btn.addEventListener('click', goHome);
});
// Back to top click
const backToTopBtn = document.getElementById('back-to-top');
if (backToTopBtn) {
backToTopBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
}
// Share function - Web Share API or modal with share links
function setupShareButton() {
const shareBtn = document.getElementById('share-btn-top');
if (!shareBtn) return;
const url = window.location.href;
const title = document.title;
// Check if Web Share API is supported
if (navigator.share) {
// Use native share
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({
title: title,
url: url
});
// Success feedback
shareBtn.classList.add('bg-green-50', 'border-green-300');
setTimeout(() => {
shareBtn.classList.remove('bg-green-50', 'border-green-300');
}, 1000);
} catch (err: unknown) {
// User cancelled - no feedback needed
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
} else {
// Show modal with share links
shareBtn.addEventListener('click', () => {
showShareModal(title, url);
});
}
}
// Show share modal with links
function showShareModal(title: string, url: string) {
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
// Create modal
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-[200] flex items-center justify-center p-4';
modal.innerHTML = `
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity" id="share-modal-backdrop"></div>
<div class="relative bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all scale-100" id="share-modal-content">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-900">Share this post</h3>
<button id="close-share-modal" class="p-2 hover:bg-slate-100 rounded-full transition-colors" aria-label="Close">
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3">
<a href="mailto:?subject=${encodedTitle}&body=${encodedUrl}"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">Email</div>
<div class="text-sm text-slate-500">Send via email</div>
</div>
</a>
<a href="https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-sky-50 rounded-full flex items-center justify-center group-hover:bg-sky-100 transition-colors">
<svg class="w-5 h-5 text-sky-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">X (Twitter)</div>
<div class="text-sm text-slate-500">Share on X</div>
</div>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">LinkedIn</div>
<div class="text-sm text-slate-500">Share professionally</div>
</div>
</a>
<button id="copy-link-btn"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group w-full text-left"
title="${url}">
<div class="w-10 h-10 bg-slate-50 rounded-full flex items-center justify-center group-hover:bg-slate-100 transition-colors">
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-slate-900">Copy Link</div>
<div class="text-sm text-slate-500 truncate" id="copy-link-text">${url}</div>
</div>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.body.style.overflow = 'hidden';
// Close modal handlers
const closeBtn = modal.querySelector('#close-share-modal');
const backdrop = modal.querySelector('#share-modal-backdrop');
const copyBtn = modal.querySelector('#copy-link-btn');
const closeModal = () => {
const content = modal.querySelector('#share-modal-content') as HTMLElement;
if (content) {
content.style.transform = 'scale(0.95)';
content.style.opacity = '0';
}
if (backdrop) {
(backdrop as HTMLElement).style.opacity = '0';
}
setTimeout(() => {
modal.remove();
document.body.style.overflow = '';
}, 200);
};
if (closeBtn) closeBtn.addEventListener('click', closeModal);
if (backdrop) backdrop.addEventListener('click', closeModal);
// Copy link functionality
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
const displayText = document.getElementById('copy-link-text');
const originalText = displayText?.textContent || '';
try {
await navigator.clipboard.writeText(url);
if (displayText) {
displayText.textContent = '✓ Copied!';
displayText.classList.add('text-green-600');
setTimeout(() => {
displayText.textContent = originalText;
displayText.classList.remove('text-green-600');
}, 2000);
}
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (displayText) {
displayText.textContent = '✓ Copied!';
displayText.classList.add('text-green-600');
setTimeout(() => {
displayText.textContent = originalText;
displayText.classList.remove('text-green-600');
}, 2000);
}
} catch (err2) {
alert('Could not copy link. Please copy manually: ' + url);
}
document.body.removeChild(textArea);
}
});
}
}
// Initialize all
document.addEventListener('DOMContentLoaded', () => {
updateReadingProgress();
updateBackToTop();
setupBackNavigation();
setupShareButton();
window.addEventListener('scroll', () => {
updateReadingProgress();
updateBackToTop();
});
});
</script>
<style>
/* Enhanced typography */
.prose-slate {
color: #334155;
}
.prose-slate p {
margin-bottom: 1.75rem;
line-height: 1.85;
font-size: 1.125rem;
}
.prose-slate h2 {
margin-top: 2.75rem;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.prose-slate h3 {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.prose-slate ul,
.prose-slate ol {
margin-bottom: 1.75rem;
padding-left: 1.5rem;
}
.prose-slate li {
margin-bottom: 0.55rem;
line-height: 1.75;
}
.prose-slate blockquote {
border-left: 4px solid #cbd5e1;
padding-left: 1.5rem;
font-style: italic;
color: #475569;
margin: 1.75rem 0;
font-size: 1.125rem;
background: linear-gradient(to right, #f8fafc, #ffffff);
padding: 1rem 1.5rem 1rem 1.5rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.prose-slate code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
color: #dc2626;
font-family: 'JetBrains Mono', monospace;
}
/* Smooth transitions */
a, button {
transition: all 0.2s ease;
}
/* Focus styles for all interactive elements */
a:focus,
button:focus,
.share-button-top:focus,
#back-btn-top:focus,
#back-btn-bottom:focus,
#back-to-top:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* High contrast focus for better visibility */
@media (prefers-contrast: high) {
a:focus,
button:focus {
outline: 3px solid #000;
outline-offset: 2px;
}
}
/* Reading progress */
#reading-progress {
transition: width 0.1s ease-out;
}
/* Top nav transitions */
#top-nav {
transition: backdrop-filter 0.3s ease, background-color 0.3s ease, opacity 0.4s ease;
}
/* Back to top hover */
#back-to-top:hover {
transform: translateY(-2px) scale(1.05);
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #94a3b8, #64748b);
}
/* Selection styling */
::selection {
background: linear-gradient(to right, #3b82f6, #8b5cf6);
color: white;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Share modal animations */
#share-modal-backdrop {
transition: opacity 0.2s ease;
}
#share-modal-content {
transition: transform 0.2s ease, opacity 0.2s ease;
}
</style>
</BaseLayout>

View File

@@ -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;
---
<BaseLayout title={post.title} description={post.description}>
<!-- Top navigation -->
<nav class="fixed top-0 left-0 right-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200 transition-all duration-300" id="top-nav">
<div class="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<button
id="back-btn-top"
class="flex items-center gap-2 text-slate-700 hover:text-blue-600 transition-colors duration-200 group"
aria-label="Back to home"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span class="font-medium">Back</span>
</button>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500 font-sans hidden sm:inline">
{readingTime} min read
</span>
<button
id="share-btn-top"
class="share-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-blue-50 border border-slate-200 hover:border-blue-300 rounded-full transition-all duration-200"
aria-label="Share this post"
>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
</button>
</div>
</div>
</nav>
<!-- Main content -->
<main id="post-content" class="pt-24">
<section class="py-12 md:py-16">
<div class="max-w-3xl mx-auto px-6">
<div class="text-center">
<h1 class="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight">
{post.title}
</h1>
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans">
<time datetime={post.date} class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
</svg>
{formattedDate}
</time>
<span class="text-slate-400">•</span>
<span class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
{readingTime} min
</span>
</div>
<p class="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto">
{post.description}
</p>
<div class="flex flex-wrap justify-center gap-2 mb-8">
{post.tags.map((tag: string, index: number) => (
<Tag tag={tag} index={index} className="text-xs" />
))}
</div>
</div>
</div>
</section>
<section class="max-w-3xl mx-auto px-6 pb-24">
<div class="prose prose-slate max-w-none">
<LeadParagraph>
This post demonstrates our new free embed components that give you full styling control over YouTube videos, Twitter tweets, and other rich content - all generated at build time.
</LeadParagraph>
<H2>YouTube Embed Example</H2>
<Paragraph>
Here's a YouTube video embedded with full styling control. The component uses build-time generation for optimal performance.
</Paragraph>
<div class="my-6">
<YouTubeEmbed
videoId="dQw4w9WgXcQ"
title="Demo Video"
style="minimal"
className="my-4"
/>
</div>
<Paragraph>
You can customize the appearance using CSS variables or data attributes:
</Paragraph>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`<YouTubeEmbed
videoId="dQw4w9WgXcQ"
style="minimal" // 'default' | 'minimal' | 'rounded' | 'flat'
aspectRatio="56.25%" // Custom aspect ratio
className="my-4" // Additional classes
/>`}
/>
<H2>Twitter/X Embed Example</H2>
<Paragraph>
Twitter embeds use the official Twitter iframe embed for reliable display.
</Paragraph>
<div class="my-4">
<TwitterEmbed
tweetId="20"
theme="light"
align="center"
/>
</div>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`<TwitterEmbed
tweetId="20"
theme="light" // 'light' | 'dark'
align="center" // 'left' | 'center' | 'right'
/>`}
/>
<H2>Generic Embed Example</H2>
<Paragraph>
The generic component supports direct embeds for Vimeo, CodePen, GitHub Gists, and other platforms.
</Paragraph>
<div class="my-6">
<GenericEmbed
url="https://vimeo.com/123456789"
type="video"
maxWidth="800px"
/>
</div>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`<GenericEmbed
url="https://vimeo.com/123456789"
type="video" // 'video' | 'article' | 'rich'
maxWidth="800px"
/>`}
/>
<H2>Mermaid Diagrams</H2>
<Paragraph>
We've added support for Mermaid diagrams! You can now create flowcharts, sequence diagrams, and more using a simple text-based syntax.
</Paragraph>
<div class="my-8">
<Mermaid
graph={`graph LR
A[Client] --> B[Load Balancer]
B --> C[App Server 1]
B --> D[App Server 2]
C --> E[(Database)]
D --> E`}
/>
</div>
<Paragraph>
Usage is straightforward:
</Paragraph>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`<Mermaid
graph={\`graph LR
A[Client] --> B[Load Balancer]
B --> C[App Server 1]
B --> D[App Server 2]
C --> E[(Database)]
D --> E\`}
/>`}
/>
<H2>Styling Control</H2>
<Paragraph>
All components use CSS variables for easy customization:
</Paragraph>
<CodeBlock
language="css"
showLineNumbers={true}
code={`.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;
}`}
/>
<H2>Benefits</H2>
<UL>
<LI><strong>Free:</strong> No paid services required</LI>
<LI><strong>Fast:</strong> Build-time generation, no runtime API calls</LI>
<LI><strong>Flexible:</strong> Full styling control via CSS variables</LI>
<LI><strong>Self-hosted:</strong> Complete ownership and privacy</LI>
<LI><strong>SEO-friendly:</strong> Static HTML content</LI>
</UL>
<H2>Integration</H2>
<Paragraph>
Simply import the components in your blog posts:
</Paragraph>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`---
import YouTubeEmbed from '../components/YouTubeEmbed.astro';
import TwitterEmbed from '../components/TwitterEmbed.astro';
import GenericEmbed from '../components/GenericEmbed.astro';
---
<YouTubeEmbed videoId="abc123" style="rounded" />
<TwitterEmbed tweetId="123456789" theme="dark" />`}
/>
</div>
<!-- Footer -->
<div class="mt-16 pt-8 border-t border-slate-200 text-center">
<button
id="back-btn-bottom"
class="inline-flex items-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-full hover:border-blue-400 hover:text-blue-600 hover:shadow-md transition-all duration-300 font-medium group"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</button>
</div>
</section>
</main>
<script>
// Reading progress bar
function updateReadingProgress() {
const section = document.querySelector('section:last-child') as HTMLElement;
const progressBar = document.getElementById('reading-progress');
if (!section || !progressBar) return;
const sectionTop = section.offsetTop;
const sectionHeight = section.offsetHeight;
const windowHeight = window.innerHeight;
const scrollTop = window.scrollY;
const progress = Math.min(
Math.max((scrollTop - sectionTop + windowHeight * 0.3) / (sectionHeight - windowHeight * 0.3), 0),
1
);
progressBar.style.width = `${progress * 100}%`;
const topNav = document.getElementById('top-nav');
if (topNav) {
if (scrollTop > 100) {
topNav.style.backdropFilter = 'blur(12px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
} else {
topNav.style.backdropFilter = 'blur(8px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.8)';
}
}
}
// Back navigation
function setupBackNavigation() {
const backButtons = [
document.getElementById('back-btn-top'),
document.getElementById('back-btn-bottom')
];
const goHome = () => {
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(() => {
window.location.href = '/?from=post';
}, 500);
};
backButtons.forEach(btn => {
if (btn) btn.addEventListener('click', goHome);
});
}
// Share functionality
function setupShareButton() {
const shareBtn = document.getElementById('share-btn-top');
if (!shareBtn) return;
const url = window.location.href;
const title = document.title;
if (navigator.share) {
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({ title, url });
shareBtn.classList.add('bg-green-50', 'border-green-300');
setTimeout(() => {
shareBtn.classList.remove('bg-green-50', 'border-green-300');
}, 1000);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
updateReadingProgress();
setupBackNavigation();
setupShareButton();
window.addEventListener('scroll', updateReadingProgress);
});
</script>
<style>
/* Enhanced typography */
.prose-slate {
color: #334155;
}
.prose-slate p {
margin-bottom: 1.75rem;
line-height: 1.85;
font-size: 1.125rem;
}
.prose-slate h2 {
margin-top: 2.75rem;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.prose-slate h3 {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.prose-slate ul,
.prose-slate ol {
margin-bottom: 1.75rem;
padding-left: 1.5rem;
}
.prose-slate li {
margin-bottom: 0.55rem;
line-height: 1.75;
}
.prose-slate blockquote {
border-left: 4px solid #cbd5e1;
padding-left: 1.5rem;
font-style: italic;
color: #475569;
margin: 1.75rem 0;
font-size: 1.125rem;
background: linear-gradient(to right, #f8fafc, #ffffff);
padding: 1rem 1.5rem 1rem 1.5rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.prose-slate code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
color: #dc2626;
font-family: 'JetBrains Mono', monospace;
}
/* Smooth transitions */
a, button {
transition: all 0.2s ease;
}
/* Focus styles */
a:focus,
button:focus,
.share-button-top:focus,
#back-btn-top:focus,
#back-btn-bottom:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* Top nav transitions */
#top-nav {
transition: backdrop-filter 0.3s ease, background-color 0.3s ease, opacity 0.4s ease;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #94a3b8, #64748b);
}
/* Selection styling */
::selection {
background: linear-gradient(to right, #3b82f6, #8b5cf6);
color: white;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</BaseLayout>

View File

@@ -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))];
---
<BaseLayout title="Home" description="A public notebook of technical problem solving, mistakes, and learning notes">
<!-- Everything on ONE minimalist page -->
<!-- Clean Hero Section -->
<section class="pt-10 pb-8 md:pt-12 md:pb-10 relative">
<!-- Animated Background -->
<div class="absolute inset-0 bg-gradient-to-br from-white via-slate-50/30 to-blue-50/20 animate-gradient-shift"></div>
<!-- Morphing Blob -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-48 h-48 bg-gradient-to-br from-blue-200/15 via-purple-200/10 to-indigo-200/15 animate-morph"></div>
</div>
<!-- Animated Drawing Paths -->
<svg class="absolute inset-0 w-full h-full pointer-events-none" viewBox="0 0 100 100">
<path d="M10,50 Q50,10 90,50 T90,90" stroke="rgba(59,130,246,0.1)" stroke-width="0.5" fill="none" class="animate-draw"></path>
<path d="M10,70 Q50,30 90,70" stroke="rgba(147,51,234,0.1)" stroke-width="0.5" fill="none" class="animate-draw-delay"></path>
<path d="M20,20 Q50,80 80,20" stroke="rgba(16,185,129,0.1)" stroke-width="0.5" fill="none" class="animate-draw-reverse"></path>
</svg>
<!-- Floating Shapes -->
<div class="absolute top-10 left-10 w-20 h-20 bg-blue-100/20 rounded-full animate-float-1"></div>
<div class="absolute top-20 right-20 w-16 h-16 bg-indigo-100/20 rotate-45 animate-float-2"></div>
<div class="absolute bottom-20 left-1/4 w-12 h-12 bg-purple-100/20 rounded-full animate-float-3"></div>
<div class="absolute bottom-10 right-1/3 w-24 h-24 bg-cyan-100/20 animate-float-4"></div>
<div class="max-w-3xl mx-auto px-6 relative z-10">
<div class="text-center animate-fade-in">
<h1 class="text-3xl md:text-4xl font-serif font-light text-slate-900 tracking-tight mb-3">
Marc Mintel
</h1>
<p class="text-base md:text-lg text-slate-600 leading-relaxed font-serif italic">
"A public notebook of things I figured out, mistakes I made, and tools I tested."
</p>
<div class="flex items-center justify-center gap-3 text-[13px] text-slate-500 font-sans mt-3">
<span class="inline-flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 12a4 4 0 110-8 4 4 0 010 8z"/></svg>
Vulkaneifel, Germany
</span>
<span aria-hidden="true">•</span>
<span>Digital problem solver</span>
</div>
</div>
</div>
</section>
<!-- Search -->
<section class="mb-8 mt-8">
<div id="search-container">
<SearchBar />
</div>
</section>
<!-- Topics -->
{allTags.length > 0 && (
<section class="mb-8">
<h2 class="text-lg font-semibold text-slate-800 mb-4">Topics</h2>
<div class="tag-cloud">
{allTags.map((tag, index) => (
<a
href="#"
data-tag={tag}
onclick={`filterByTag('${tag}'); return false;`}
class="inline-block"
>
<Tag tag={tag} index={index} />
</a>
))}
</div>
</section>
)}
<!-- All Posts -->
<section>
<div id="posts-container" class="not-prose grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{allPosts.length === 0 ? (
<div class="empty-state">
<p>No posts yet. Check back soon!</p>
</div>
) : (
allPosts.map(post => (
<a
href={`/blog/${post.slug}`}
class="post-link"
data-slug={post.slug}
data-title={post.title}
data-description={post.description}
data-date={post.date}
data-tags={post.tags?.join(',')}
>
<MediumCard post={post} />
</a>
))
)}
</div>
</section>
<!-- Transition overlay for smooth page transitions -->
<div id="transition-overlay" class="fixed inset-0 bg-white z-[100] pointer-events-none opacity-0 transition-opacity duration-500"></div>
<script>
// Enhanced client-side functionality with smooth transitions
document.addEventListener('DOMContentLoaded', () => {
const searchContainer = document.getElementById('search-container');
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
const transitionOverlay = document.getElementById('transition-overlay');
// Search functionality
if (searchContainer) {
const searchInput = searchContainer.querySelector('input');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value.toLowerCase().trim();
if (!allPosts) return;
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(query) || query === '') {
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)';
}
});
});
}
}
// Global function for tag filtering
(window as any).filterByTag = function(tag: string) {
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
if (!allPosts) return;
const tagLower = tag.toLowerCase();
allPosts.forEach((post: HTMLElement) => {
const postTags = Array.from(post.querySelectorAll('.highlighter-tag'))
.map(t => t.textContent?.toLowerCase() || '');
if (postTags.includes(tagLower)) {
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)';
}
});
// Update search input to show active filter
const searchInput = document.querySelector('#search-container input');
if (searchInput) {
(searchInput as HTMLInputElement).value = `#${tag}`;
}
};
// Smooth transition to blog post with Framer Motion-style animation
const postLinks = document.querySelectorAll('.post-link') as NodeListOf<HTMLAnchorElement>;
postLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const href = link.getAttribute('href');
if (!href || !transitionOverlay) return;
// Get post data for potential future use
const slug = link.getAttribute('data-slug');
const title = link.getAttribute('data-title');
// Capture title position for layout animation
const titleElement = link.querySelector('[data-post-title]') as HTMLElement;
if (titleElement && slug) {
const rect = titleElement.getBoundingClientRect();
const titleData = {
text: titleElement.textContent || '',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
fontSize: window.getComputedStyle(titleElement).fontSize,
fontWeight: window.getComputedStyle(titleElement).fontWeight,
color: window.getComputedStyle(titleElement).color
};
sessionStorage.setItem(`title-position-${slug}`, JSON.stringify(titleData));
}
// Add click ripple effect to the card
const card = link.querySelector('.post-card') as HTMLElement;
if (card) {
card.style.transform = 'scale(0.98)';
card.style.transition = 'transform 0.2s ease';
setTimeout(() => {
card.style.transform = 'scale(1)';
}, 150);
}
// Framer Motion-style page transition
// 1. Fade in overlay
transitionOverlay.style.opacity = '1';
transitionOverlay.style.pointerEvents = 'auto';
// 2. Scale down current content
const mainContent = document.querySelector('main');
if (mainContent) {
mainContent.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
mainContent.style.transform = 'scale(0.95)';
mainContent.style.opacity = '0';
}
// 3. Navigate after animation
setTimeout(() => {
window.location.href = href;
}, 400);
});
// Hover handled by CSS
});
// Handle incoming transition (when coming back from post)
const urlParams = new URLSearchParams(window.location.search);
const fromPost = urlParams.get('from');
if (fromPost && transitionOverlay) {
// Reverse animation - fade out overlay and scale up content
setTimeout(() => {
transitionOverlay.style.opacity = '0';
transitionOverlay.style.pointerEvents = 'none';
const mainContent = document.querySelector('main');
if (mainContent) {
mainContent.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
mainContent.style.transform = 'scale(1)';
mainContent.style.opacity = '1';
}
}, 50);
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
}
// Stagger animation for posts on load
if (allPosts) {
allPosts.forEach((post, index) => {
post.style.opacity = '0';
post.style.transform = 'translateY(10px)';
post.style.transition = `opacity 0.4s ease-out ${index * 0.05}s, transform 0.4s ease-out ${index * 0.05}s`;
setTimeout(() => {
post.style.opacity = '1';
post.style.transform = 'translateY(0)';
}, 100);
});
}
});
</script>
<style is:global>
/* Post link wrapper for smooth transitions */
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-link .post-card {
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
}
.post-link:hover .post-card {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
border-color: rgba(148, 163, 184, 0.9);
}
.post-link:active .post-card {
transform: translateY(0) scale(0.99);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
/* Transition overlay */
#transition-overlay {
backdrop-filter: blur(10px);
}
/* Main content transition container */
main {
transform-origin: center top;
}
/* Smooth animations for interactive elements only */
a,
button,
input,
.post-link,
.highlighter-tag {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
/* Focus styles for all interactive elements */
a:focus,
button:focus,
.post-link:focus,
.highlighter-tag:focus,
input:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* Remove default outline in favor of custom styles */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
a:focus,
button:focus,
input:focus {
outline: 3px solid #000;
outline-offset: 2px;
}
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out both;
}
/* Enhanced hover states for tags */
.tag-cloud a {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.tag-cloud a:hover {
transform: translateY(-1px) rotate(-1deg);
box-shadow: none;
}
/* Prevent hover transforms from affecting layout/neighbor borders */
.tag-cloud {
overflow: visible;
}
.tag-cloud a {
display: inline-block;
padding: 2px;
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* 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;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Animated gradient shift */
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.animate-gradient-shift {
background-size: 200% 200%;
animation: gradient-shift 20s ease infinite;
}
/* Floating animations */
@keyframes float-1 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
.animate-float-1 {
animation: float-1 15s ease-in-out infinite;
}
@keyframes float-2 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(90deg); }
}
.animate-float-2 {
animation: float-2 18s ease-in-out infinite;
}
@keyframes float-3 {
0%, 100% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-10px) scale(1.1); }
}
.animate-float-3 {
animation: float-3 12s ease-in-out infinite;
}
@keyframes float-4 {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-25px) rotate(-90deg); }
}
.animate-float-4 {
animation: float-4 20s ease-in-out infinite;
}
/* Morphing blob animation */
@keyframes morph {
0%, 100% {
border-radius: 50%;
transform: scale(1) rotate(0deg);
}
25% {
border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%;
transform: scale(1.1) rotate(90deg);
}
50% {
border-radius: 20% 80% 20% 80% / 80% 20% 80% 20%;
transform: scale(0.9) rotate(180deg);
}
75% {
border-radius: 70% 30% 50% 50% / 50% 70% 30% 50%;
transform: scale(1.05) rotate(270deg);
}
}
.animate-morph {
animation: morph 25s ease-in-out infinite;
}
/* Drawing animations */
@keyframes draw {
to { stroke-dashoffset: 0; }
}
.animate-draw {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate;
}
.animate-draw-delay {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate 4s;
}
.animate-draw-reverse {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 8s ease-in-out infinite alternate-reverse 2s;
}
</style>
</BaseLayout>

View File

@@ -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));
---
<BaseLayout title={`Posts tagged "${tag}"`} description={`All posts tagged with ${tag}`}>
<div class="max-w-3xl mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-slate-900 mb-2">
Posts tagged <span class="highlighter-yellow">{tag}</span>
</h1>
<p class="text-slate-600">
{posts.length} post{posts.length === 1 ? '' : 's'}
</p>
</header>
<div class="space-y-4">
{posts.map(post => (
<MediumCard post={post} />
))}
</div>
<div class="mt-8 pt-6 border-t border-slate-200">
<a href="/" class="text-blue-600 hover:text-blue-800 inline-flex items-center">
← Back to home
</a>
</div>
</div>
</BaseLayout>

View File

@@ -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,
],
}
}

View File

@@ -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"
}
}
"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"]
}