migrate to nextjs
This commit is contained in:
18
.gitignore
vendored
18
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
89
app/api/og/[[...slug]]/route.tsx
Normal file
89
app/api/og/[[...slug]]/route.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { blogPosts } from '../../../../src/data/blogPosts';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug?: string[] }> }
|
||||
) {
|
||||
const { slug: slugArray } = await params;
|
||||
const slug = slugArray?.[0] || 'home';
|
||||
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
if (slug === 'home') {
|
||||
title = 'Marc Mintel';
|
||||
description = 'Technical problem solver\'s blog - practical insights and learning notes';
|
||||
} else {
|
||||
const post = blogPosts.find(p => p.slug === slug);
|
||||
title = post?.title || 'Marc Mintel';
|
||||
description = (post?.description || 'Technical problem solver\'s blog - practical insights and learning notes').slice(0, 100);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
padding: '60px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
right: '60px',
|
||||
width: '120px',
|
||||
height: '4px',
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 700,
|
||||
color: '#1e293b',
|
||||
marginBottom: '20px',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 400,
|
||||
color: '#64748b',
|
||||
marginBottom: 'auto',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 500,
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
mintel.me
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
215
app/blog/[slug]/page.tsx
Normal file
215
app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { blogPosts } from '../../../src/data/blogPosts';
|
||||
import { Tag } from '../../../src/components/Tag';
|
||||
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
|
||||
import { H2 } from '../../../src/components/ArticleHeading';
|
||||
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
|
||||
import { UL, LI } from '../../../src/components/ArticleList';
|
||||
import { FileExamplesList } from '../../../src/components/FileExamplesList';
|
||||
import { FileExampleManager } from '../../../src/data/fileExamples';
|
||||
import { BlogPostClient } from '../../../src/components/BlogPostClient';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return blogPosts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const post = blogPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
const wordCount = post.description.split(/\s+/).length + 100;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
// Load file examples for the post
|
||||
let groups: any[] = [];
|
||||
if (showFileExamples) {
|
||||
const allGroups = await FileExampleManager.getAllGroups();
|
||||
groups = allGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
files: group.files.filter((file) => {
|
||||
if (file.postSlug !== slug) return false;
|
||||
return true;
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.files.length > 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<BlogPostClient readingTime={readingTime} title={post.title} />
|
||||
|
||||
<main id="post-content" className="pt-24">
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="max-w-3xl mx-auto px-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans">
|
||||
<time dateTime={post.date} className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
|
||||
</svg>
|
||||
{formattedDate}
|
||||
</time>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
{readingTime} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto">
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-8">
|
||||
{post.tags.map((tag, index) => (
|
||||
<Tag key={tag} tag={tag} index={index} className="text-xs" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-3xl mx-auto px-6 pb-24">
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p>{post.description}</p>
|
||||
|
||||
{slug === 'first-note' && (
|
||||
<>
|
||||
<LeadParagraph>
|
||||
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
|
||||
</LeadParagraph>
|
||||
<H2>Why write in public?</H2>
|
||||
<Paragraph>
|
||||
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
|
||||
</Paragraph>
|
||||
<H2>What to expect</H2>
|
||||
<UL>
|
||||
<LI>Short entries, usually under 500 words</LI>
|
||||
<LI>Practical solutions to specific problems</LI>
|
||||
<LI>Notes on tools and workflows</LI>
|
||||
<LI>Mistakes and what I learned</LI>
|
||||
</UL>
|
||||
</>
|
||||
)}
|
||||
|
||||
{slug === 'debugging-tips' && (
|
||||
<>
|
||||
<LeadParagraph>
|
||||
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
|
||||
</LeadParagraph>
|
||||
<H2>Why print statements work</H2>
|
||||
<Paragraph>
|
||||
Debuggers are powerful, but they change how your code runs. Print statements don't.
|
||||
</Paragraph>
|
||||
<CodeBlock language="python" showLineNumbers={true}>
|
||||
{`def process_data(data):
|
||||
print(f"Processing {len(data)} items")
|
||||
result = expensive_operation(data)
|
||||
print(f"Operation result: {result}")
|
||||
return result`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Complete examples</H2>
|
||||
<Paragraph>
|
||||
Here are some practical file examples you can copy and download. These include proper error handling and logging.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-8">
|
||||
<FileExamplesList groups={groups} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{slug === 'architecture-patterns' && (
|
||||
<>
|
||||
<LeadParagraph>
|
||||
Good software architecture is about making the right decisions early. Here are some patterns I've found useful in production systems.
|
||||
</LeadParagraph>
|
||||
<H2>Repository Pattern</H2>
|
||||
<Paragraph>
|
||||
The repository pattern provides a clean separation between your business logic and data access layer. It makes your code more testable and maintainable.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Service Layer</H2>
|
||||
<Paragraph>
|
||||
Services orchestrate business logic and coordinate between repositories and domain events. They keep your controllers thin and your business rules organized.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Domain Events</H2>
|
||||
<Paragraph>
|
||||
Domain events help you decouple components and react to changes in your system. They're essential for building scalable, event-driven architectures.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Complete examples</H2>
|
||||
<Paragraph>
|
||||
These TypeScript examples demonstrate modern architecture patterns for scalable applications. You can copy them directly into your project.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-8">
|
||||
<FileExamplesList groups={groups} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{slug === 'docker-deployment' && (
|
||||
<>
|
||||
<LeadParagraph>
|
||||
Docker has become the standard for containerizing applications. Here's how to set up production-ready deployments that are secure, efficient, and maintainable.
|
||||
</LeadParagraph>
|
||||
<H2>Multi-stage builds</H2>
|
||||
<Paragraph>
|
||||
Multi-stage builds keep your production images small and secure by separating build and runtime environments. This reduces attack surface and speeds up deployments.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Health checks and monitoring</H2>
|
||||
<Paragraph>
|
||||
Proper health checks ensure your containers are running correctly. Combined with restart policies, this gives you resilient, self-healing deployments.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Orchestration with Docker Compose</H2>
|
||||
<Paragraph>
|
||||
Docker Compose makes it easy to manage multi-service applications in development and production. Define services, networks, and volumes in a single file.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Complete examples</H2>
|
||||
<Paragraph>
|
||||
These Docker configurations are production-ready. Use them as a starting point for your own deployments.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-8">
|
||||
<FileExamplesList groups={groups} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
app/blog/embed-demo/page.tsx
Normal file
242
app/blog/embed-demo/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import { Tag } from '../../../src/components/Tag';
|
||||
import { H2 } from '../../../src/components/ArticleHeading';
|
||||
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
|
||||
import { UL, LI } from '../../../src/components/ArticleList';
|
||||
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
|
||||
import { YouTubeEmbed } from '../../../src/components/YouTubeEmbed';
|
||||
import { TwitterEmbed } from '../../../src/components/TwitterEmbed';
|
||||
import { GenericEmbed } from '../../../src/components/GenericEmbed';
|
||||
import { Mermaid } from '../../../src/components/Mermaid';
|
||||
import { BlogPostClient } from '../../../src/components/BlogPostClient';
|
||||
|
||||
export default function EmbedDemoPage() {
|
||||
const post = {
|
||||
title: "Rich Content Embedding Demo",
|
||||
description: "Testing our new free embed components for YouTube, Twitter, Mermaid diagrams, and other platforms",
|
||||
date: "2024-02-15",
|
||||
slug: "embed-demo",
|
||||
tags: ["embeds", "components", "tutorial", "mermaid"]
|
||||
};
|
||||
|
||||
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
const readingTime = 5;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<BlogPostClient readingTime={readingTime} title={post.title} />
|
||||
|
||||
<main id="post-content" className="pt-24">
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="max-w-3xl mx-auto px-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans">
|
||||
<time dateTime={post.date} className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
|
||||
</svg>
|
||||
{formattedDate}
|
||||
</time>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
{readingTime} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto">
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-8">
|
||||
{post.tags.map((tag, index) => (
|
||||
<Tag key={tag} tag={tag} index={index} className="text-xs" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-3xl mx-auto px-6 pb-24">
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<LeadParagraph>
|
||||
This post demonstrates our new free embed components that give you full styling control over YouTube videos, Twitter tweets, and other rich content - all generated at build time.
|
||||
</LeadParagraph>
|
||||
|
||||
<H2>YouTube Embed Example</H2>
|
||||
<Paragraph>
|
||||
Here's a YouTube video embedded with full styling control. The component uses build-time generation for optimal performance.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-6">
|
||||
<YouTubeEmbed
|
||||
videoId="dQw4w9WgXcQ"
|
||||
title="Demo Video"
|
||||
style="minimal"
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Paragraph>
|
||||
You can customize the appearance using CSS variables or data attributes:
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock
|
||||
language="jsx"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`<YouTubeEmbed
|
||||
videoId="dQw4w9WgXcQ"
|
||||
style="minimal" // 'default' | 'minimal' | 'rounded' | 'flat'
|
||||
aspectRatio="56.25%" // Custom aspect ratio
|
||||
className="my-4" // Additional classes
|
||||
/>`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Twitter/X Embed Example</H2>
|
||||
<Paragraph>
|
||||
Twitter embeds use the official Twitter iframe embed for reliable display.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-4">
|
||||
<TwitterEmbed
|
||||
tweetId="20"
|
||||
theme="light"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeBlock
|
||||
language="jsx"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`<TwitterEmbed
|
||||
tweetId="20"
|
||||
theme="light" // 'light' | 'dark'
|
||||
align="center" // 'left' | 'center' | 'right'
|
||||
/>`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Generic Embed Example</H2>
|
||||
<Paragraph>
|
||||
The generic component supports direct embeds for Vimeo, CodePen, GitHub Gists, and other platforms.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-6">
|
||||
<GenericEmbed
|
||||
url="https://vimeo.com/123456789"
|
||||
type="video"
|
||||
maxWidth="800px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeBlock
|
||||
language="jsx"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`<GenericEmbed
|
||||
url="https://vimeo.com/123456789"
|
||||
type="video" // 'video' | 'article' | 'rich'
|
||||
maxWidth="800px"
|
||||
/>`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Mermaid Diagrams</H2>
|
||||
<Paragraph>
|
||||
We've added support for Mermaid diagrams! You can now create flowcharts, sequence diagrams, and more using a simple text-based syntax.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-8">
|
||||
<Mermaid
|
||||
graph={`graph LR
|
||||
A[Client] --> B[Load Balancer]
|
||||
B --> C[App Server 1]
|
||||
B --> D[App Server 2]
|
||||
C --> E[(Database)]
|
||||
D --> E`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Paragraph>
|
||||
Usage is straightforward:
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock
|
||||
language="jsx"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`<Mermaid
|
||||
graph={\`graph LR
|
||||
A[Client] --> B[Load Balancer]
|
||||
B --> C[App Server 1]
|
||||
B --> D[App Server 2]
|
||||
C --> E[(Database)]
|
||||
D --> E\`}
|
||||
/>`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Styling Control</H2>
|
||||
<Paragraph>
|
||||
All components use CSS variables for easy customization:
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock
|
||||
language="css"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`.youtube-embed {
|
||||
--aspect-ratio: 56.25%;
|
||||
--bg-color: #000000;
|
||||
--border-radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Data attribute variations */
|
||||
.youtube-embed[data-style="minimal"] {
|
||||
--border-radius: 4px;
|
||||
--shadow: none;
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<H2>Benefits</H2>
|
||||
<UL>
|
||||
<LI><strong>Free:</strong> No paid services required</LI>
|
||||
<LI><strong>Fast:</strong> Build-time generation, no runtime API calls</LI>
|
||||
<LI><strong>Flexible:</strong> Full styling control via CSS variables</LI>
|
||||
<LI><strong>Self-hosted:</strong> Complete ownership and privacy</LI>
|
||||
<LI><strong>SEO-friendly:</strong> Static HTML content</LI>
|
||||
</UL>
|
||||
|
||||
<H2>Integration</H2>
|
||||
<Paragraph>
|
||||
Simply import the components in your blog posts:
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock
|
||||
language="jsx"
|
||||
showLineNumbers={true}
|
||||
>
|
||||
{`import { YouTubeEmbed } from '../components/YouTubeEmbed';
|
||||
import { TwitterEmbed } from '../components/TwitterEmbed';
|
||||
import { GenericEmbed } from '../components/GenericEmbed';
|
||||
|
||||
<YouTubeEmbed videoId="abc123" style="rounded" />
|
||||
<TwitterEmbed tweetId="123456789" theme="dark" />`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
695
app/globals.css
Normal file
695
app/globals.css
Normal file
@@ -0,0 +1,695 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base styles - Tailwind only */
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-sans font-bold text-slate-900;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl md:text-4xl leading-tight mb-6;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl md:text-3xl leading-tight mb-4 mt-8;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-2xl leading-tight mb-3 mt-6;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg md:text-xl leading-tight mb-2 mt-4;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
.lead {
|
||||
@apply text-xl md:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-600 hover:text-blue-800 transition-colors;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
@apply ml-5 mb-4;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
code:not([class*='language-']) {
|
||||
@apply bg-slate-100 px-1 py-0.5 rounded font-mono text-sm text-slate-700;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-2 border-slate-300 pl-4 italic text-slate-600 my-4;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
a:focus,
|
||||
button:focus,
|
||||
input:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Components - Tailwind utility classes */
|
||||
@layer components {
|
||||
/* Legacy hooks required by tests */
|
||||
.file-example {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply max-w-4xl mx-auto px-6 py-10;
|
||||
}
|
||||
|
||||
.wide-container {
|
||||
@apply max-w-5xl mx-auto px-6 py-12;
|
||||
}
|
||||
|
||||
.narrow-container {
|
||||
@apply max-w-2xl mx-auto px-6 py-8;
|
||||
}
|
||||
|
||||
.highlighter-tag {
|
||||
@apply inline-block text-xs font-bold px-2 py-0.5 rounded cursor-pointer transition-all duration-200;
|
||||
position: relative;
|
||||
transform: rotate(-1deg);
|
||||
box-shadow: 2px 2px 0 rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
@apply w-full px-4 py-3 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-blue-400 transition-colors;
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-box::placeholder {
|
||||
@apply text-slate-400;
|
||||
}
|
||||
|
||||
/* Blog post card */
|
||||
.post-card {
|
||||
@apply mb-8 last:mb-0;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
@apply text-xs text-slate-500 font-sans mb-2;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
@apply text-slate-700 mb-2 leading-relaxed;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
@apply flex flex-wrap gap-1;
|
||||
}
|
||||
|
||||
/* Article page */
|
||||
.article-header {
|
||||
@apply mb-8;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
@apply text-4xl md:text-5xl font-bold mb-3;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
@apply text-sm text-slate-500 font-sans mb-5;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
@apply text-lg leading-relaxed;
|
||||
}
|
||||
|
||||
.article-content p {
|
||||
@apply mb-5;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
@apply text-2xl font-bold mt-8 mb-3;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
@apply text-xl font-bold mt-6 mb-2;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
@apply ml-6 mb-5;
|
||||
}
|
||||
|
||||
.article-content li {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
@apply border-l-2 border-slate-400 pl-4 italic text-slate-600 my-5 text-lg;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply inline-block px-4 py-2 bg-slate-900 text-white font-sans font-medium hover:bg-slate-700 transition-colors rounded;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 hover:bg-slate-100 border border-slate-300 px-3 py-1.5 rounded transition-colors;
|
||||
}
|
||||
|
||||
/* Hide scrollbars */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply text-center py-8 text-slate-500;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
@apply mx-auto mb-2 text-slate-300;
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Reading progress indicator */
|
||||
.reading-progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #3b82f6;
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
z-index: 50;
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Floating back to top button */
|
||||
.floating-back-to-top {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.floating-back-to-top.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.floating-back-to-top:hover {
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.floating-back-to-top,
|
||||
.reading-progress-bar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional global styles from BaseLayout */
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* Tag Styles */
|
||||
.highlighter-tag {
|
||||
transform: rotate(-1deg) translateY(0);
|
||||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
animation: tagPopIn 0.3s ease-out both;
|
||||
animation-delay: calc(var(--tag-index, 0) * 0.05s);
|
||||
}
|
||||
|
||||
.highlighter-tag:hover {
|
||||
transform: rotate(-2deg) translateY(-2px) scale(1.05);
|
||||
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@keyframes tagPopIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotate(-1deg) scale(0.8) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(-1deg) scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.highlighter-yellow {
|
||||
background: linear-gradient(135deg, rgba(255, 235, 59, 0.95) 0%, rgba(255, 213, 79, 0.95) 100%);
|
||||
color: #3f2f00;
|
||||
}
|
||||
|
||||
.highlighter-pink {
|
||||
background: linear-gradient(135deg, rgba(255, 167, 209, 0.95) 0%, rgba(255, 122, 175, 0.95) 100%);
|
||||
color: #3f0018;
|
||||
}
|
||||
|
||||
.highlighter-green {
|
||||
background: linear-gradient(135deg, rgba(129, 199, 132, 0.95) 0%, rgba(102, 187, 106, 0.95) 100%);
|
||||
color: #002f0a;
|
||||
}
|
||||
|
||||
.highlighter-blue {
|
||||
background: linear-gradient(135deg, rgba(100, 181, 246, 0.95) 0%, rgba(66, 165, 245, 0.95) 100%);
|
||||
color: #001f3f;
|
||||
}
|
||||
|
||||
.highlighter-tag:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: inherit;
|
||||
filter: blur(8px);
|
||||
opacity: 0.4;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.highlighter-tag:active {
|
||||
transform: rotate(-1deg) translateY(0) scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.highlighter-tag:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
transform: rotate(-1deg) translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3), 3px 3px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Marker Title Styles */
|
||||
.marker-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.15em;
|
||||
right: -0.15em;
|
||||
bottom: 0.05em;
|
||||
height: 0.62em;
|
||||
border-radius: 0.18em;
|
||||
z-index: -1;
|
||||
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 20%,
|
||||
rgba(253, 230, 138, 0.70) 20%,
|
||||
rgba(253, 230, 138, 0.70) 100%
|
||||
);
|
||||
|
||||
transform-origin: left center;
|
||||
transform:
|
||||
rotate(calc((var(--marker-seed, 0) - 3) * 0.45deg))
|
||||
skewX(calc((var(--marker-seed, 0) - 3) * -0.25deg));
|
||||
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
.marker-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.18em;
|
||||
right: -0.05em;
|
||||
bottom: 0.05em;
|
||||
height: 0.62em;
|
||||
border-radius: 0.18em;
|
||||
z-index: -1;
|
||||
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(253, 230, 138, 0.00) 0%,
|
||||
rgba(253, 230, 138, 0.60) 8%,
|
||||
rgba(253, 230, 138, 0.55) 60%,
|
||||
rgba(253, 230, 138, 0.35) 100%
|
||||
);
|
||||
|
||||
opacity: 0.75;
|
||||
mix-blend-mode: multiply;
|
||||
transform:
|
||||
rotate(calc((var(--marker-seed, 0) - 3) * 0.45deg))
|
||||
translateY(0.02em);
|
||||
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 20%,
|
||||
rgba(0, 0, 0, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.post-link:hover .marker-title::before,
|
||||
.post-link:hover .marker-title::after {
|
||||
filter: saturate(1.08) contrast(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mermaid Styles */
|
||||
.mermaid-container svg {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.mermaid-container rect,
|
||||
.mermaid-container circle,
|
||||
.mermaid-container ellipse,
|
||||
.mermaid-container polygon,
|
||||
.mermaid-container path,
|
||||
.mermaid-container .actor,
|
||||
.mermaid-container .node {
|
||||
fill: white !important;
|
||||
stroke: #cbd5e1 !important;
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
.mermaid-container .edgePath .path,
|
||||
.mermaid-container .messageLine0,
|
||||
.mermaid-container .messageLine1,
|
||||
.mermaid-container .flowchart-link {
|
||||
stroke: #cbd5e1 !important;
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
.mermaid-container text,
|
||||
.mermaid-container .label,
|
||||
.mermaid-container .labelText,
|
||||
.mermaid-container .edgeLabel,
|
||||
.mermaid-container .node text,
|
||||
.mermaid-container tspan {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
fill: #334155 !important;
|
||||
color: #334155 !important;
|
||||
stroke: none !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.mermaid-container .marker,
|
||||
.mermaid-container marker path {
|
||||
fill: #cbd5e1 !important;
|
||||
stroke: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
/* Generic Embed Styles */
|
||||
.generic-embed {
|
||||
--max-width: 100%;
|
||||
--border-radius: 8px;
|
||||
--bg-color: #ffffff;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
margin: 1.5rem 0;
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
}
|
||||
|
||||
.embed-wrapper {
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.generic-embed[data-type="video"] .embed-wrapper {
|
||||
aspect-ratio: 16/9;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%; /* 16:9 */
|
||||
}
|
||||
|
||||
.generic-embed[data-type="video"] .embed-wrapper iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embed-wrapper:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.generic-embed[data-provider="youtube.com"] {
|
||||
--bg-color: #000000;
|
||||
}
|
||||
|
||||
.generic-embed[data-provider="vimeo.com"] {
|
||||
--bg-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.generic-embed[data-provider="codepen.io"] {
|
||||
--bg-color: #1e1e1e;
|
||||
--border-color: #333;
|
||||
}
|
||||
|
||||
.embed-fallback {
|
||||
padding: 1.5rem;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: var(--border-radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fallback-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.generic-embed {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.embed-fallback {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.embed-wrapper:hover {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* File Example Styles */
|
||||
[data-file-example] {
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.copy-btn,
|
||||
.download-btn {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.copy-btn[data-copied='true'] {
|
||||
color: #065f46;
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
/* Prism.js syntax highlighting - light, low-noise */
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'],
|
||||
pre:has(code[class*='language-']) {
|
||||
color: #0f172a;
|
||||
background: transparent;
|
||||
text-shadow: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.65;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
tab-size: 2;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.token.operator {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #db2777;
|
||||
}
|
||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Footer } from '../src/components/Footer';
|
||||
import { InteractiveElements } from '../src/components/InteractiveElements';
|
||||
import { Analytics } from '../src/components/Analytics';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Marc Mintel',
|
||||
template: '%s | Marc Mintel',
|
||||
},
|
||||
description: "Technical problem solver's blog - practical insights and learning notes",
|
||||
metadataBase: new URL('https://mintel.me'),
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body className="min-h-screen bg-white">
|
||||
<main className="container">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
191
app/page.tsx
Normal file
191
app/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MediumCard } from '../src/components/MediumCard';
|
||||
import { SearchBar } from '../src/components/SearchBar';
|
||||
import { Tag } from '../src/components/Tag';
|
||||
import { blogPosts } from '../src/data/blogPosts';
|
||||
|
||||
export default function HomePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredPosts, setFilteredPosts] = useState(blogPosts);
|
||||
|
||||
// Sort posts by date
|
||||
const allPosts = [...blogPosts].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
// Get unique tags
|
||||
const allTags = Array.from(new Set(allPosts.flatMap(post => post.tags || [])));
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (query.startsWith('#')) {
|
||||
const tag = query.slice(1);
|
||||
setFilteredPosts(allPosts.filter(post =>
|
||||
post.tags?.some(t => t.toLowerCase() === tag.toLowerCase())
|
||||
));
|
||||
} else {
|
||||
setFilteredPosts(allPosts.filter(post => {
|
||||
const title = post.title.toLowerCase();
|
||||
const description = post.description.toLowerCase();
|
||||
const tags = (post.tags || []).join(' ').toLowerCase();
|
||||
return title.includes(query) || description.includes(query) || tags.includes(query);
|
||||
}));
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const filterByTag = (tag: string) => {
|
||||
setSearchQuery(`#${tag}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Clean Hero Section */}
|
||||
<section className="pt-10 pb-8 md:pt-12 md:pb-10 relative overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white via-slate-50/30 to-blue-50/20 animate-gradient-shift"></div>
|
||||
|
||||
{/* Morphing Blob */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-48 h-48 bg-gradient-to-br from-blue-200/15 via-purple-200/10 to-indigo-200/15 animate-morph"></div>
|
||||
</div>
|
||||
|
||||
{/* Animated Drawing Paths */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox="0 0 100 100">
|
||||
<path d="M10,50 Q50,10 90,50 T90,90" stroke="rgba(59,130,246,0.1)" strokeWidth="0.5" fill="none" className="animate-draw"></path>
|
||||
<path d="M10,70 Q50,30 90,70" stroke="rgba(147,51,234,0.1)" strokeWidth="0.5" fill="none" className="animate-draw-delay"></path>
|
||||
<path d="M20,20 Q50,80 80,20" stroke="rgba(16,185,129,0.1)" strokeWidth="0.5" fill="none" className="animate-draw-reverse"></path>
|
||||
</svg>
|
||||
|
||||
{/* Floating Shapes */}
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-blue-100/20 rounded-full animate-float-1"></div>
|
||||
<div className="absolute top-20 right-20 w-16 h-16 bg-indigo-100/20 rotate-45 animate-float-2"></div>
|
||||
<div className="absolute bottom-20 left-1/4 w-12 h-12 bg-purple-100/20 rounded-full animate-float-3"></div>
|
||||
<div className="absolute bottom-10 right-1/3 w-24 h-24 bg-cyan-100/20 animate-float-4"></div>
|
||||
|
||||
<div className="max-w-3xl mx-auto px-6 relative z-10">
|
||||
<div className="text-center animate-fade-in">
|
||||
<h1 className="text-3xl md:text-4xl font-serif font-light text-slate-900 tracking-tight mb-3">
|
||||
Marc Mintel
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-slate-600 leading-relaxed font-serif italic">
|
||||
"A public notebook of things I figured out, mistakes I made, and tools I tested."
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 text-[13px] text-slate-500 font-sans mt-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 12a4 4 0 110-8 4 4 0 010 8z"/></svg>
|
||||
Vulkaneifel, Germany
|
||||
</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>Digital problem solver</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Search */}
|
||||
<section className="mb-8 mt-8">
|
||||
<div id="search-container">
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Topics */}
|
||||
{allTags.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Topics</h2>
|
||||
<div className="tag-cloud flex flex-wrap gap-2">
|
||||
{allTags.map((tag, index) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => filterByTag(tag)}
|
||||
className="inline-block"
|
||||
>
|
||||
<Tag tag={tag} index={index} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* All Posts */}
|
||||
<section>
|
||||
<div id="posts-container" className="not-prose grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{filteredPosts.length === 0 ? (
|
||||
<div className="empty-state col-span-full">
|
||||
<p>No posts found matching your criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredPosts.map(post => (
|
||||
<MediumCard key={post.slug} post={post} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.animate-gradient-shift {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 20s ease infinite;
|
||||
}
|
||||
@keyframes morph {
|
||||
0%, 100% { border-radius: 50%; transform: scale(1) rotate(0deg); }
|
||||
25% { border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%; transform: scale(1.1) rotate(90deg); }
|
||||
50% { border-radius: 20% 80% 20% 80% / 80% 20% 80% 20%; transform: scale(0.9) rotate(180deg); }
|
||||
75% { border-radius: 70% 30% 50% 50% / 50% 70% 30% 50%; transform: scale(1.05) rotate(270deg); }
|
||||
}
|
||||
.animate-morph {
|
||||
animation: morph 25s ease-in-out infinite;
|
||||
}
|
||||
@keyframes draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.animate-draw {
|
||||
stroke-dasharray: 200;
|
||||
stroke-dashoffset: 200;
|
||||
animation: draw 8s ease-in-out infinite alternate;
|
||||
}
|
||||
.animate-draw-delay {
|
||||
stroke-dasharray: 200;
|
||||
stroke-dashoffset: 200;
|
||||
animation: draw 8s ease-in-out infinite alternate 4s;
|
||||
}
|
||||
.animate-draw-reverse {
|
||||
stroke-dasharray: 200;
|
||||
stroke-dashoffset: 200;
|
||||
animation: draw 8s ease-in-out infinite alternate-reverse 2s;
|
||||
}
|
||||
@keyframes float-1 {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
.animate-float-1 { animation: float-1 15s ease-in-out infinite; }
|
||||
@keyframes float-2 {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-15px) rotate(90deg); }
|
||||
}
|
||||
.animate-float-2 { animation: float-2 18s ease-in-out infinite; }
|
||||
@keyframes float-3 {
|
||||
0%, 100% { transform: translateY(0px) scale(1); }
|
||||
50% { transform: translateY(-10px) scale(1.1); }
|
||||
}
|
||||
.animate-float-3 { animation: float-3 12s ease-in-out infinite; }
|
||||
@keyframes float-4 {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-25px) rotate(-90deg); }
|
||||
}
|
||||
.animate-float-4 { animation: float-4 20s ease-in-out infinite; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in { animation: fadeIn 0.6s ease-out both; }
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/tags/[tag]/page.tsx
Normal file
41
app/tags/[tag]/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { blogPosts } from '../../../src/data/blogPosts';
|
||||
import { MediumCard } from '../../../src/components/MediumCard';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const allTags = Array.from(new Set(blogPosts.flatMap(post => post.tags || [])));
|
||||
return allTags.map(tag => ({
|
||||
tag,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
|
||||
const { tag } = await params;
|
||||
const posts = blogPosts.filter(post => post.tags?.includes(tag));
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||
Posts tagged <span className="highlighter-yellow px-2 rounded">{tag}</span>
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
{posts.length} post{posts.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map(post => (
|
||||
<MediumCard key={post.slug} post={post} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-800 inline-flex items-center">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
6
next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
257
package-lock.json
generated
257
package-lock.json
generated
@@ -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",
|
||||
|
||||
14
package.json
14
package.json
@@ -4,28 +4,24 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "npm run test:smoke",
|
||||
"test:smoke": "tsx ./scripts/smoke-test.ts",
|
||||
"test:links": "tsx ./scripts/test-links.ts",
|
||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.13",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@astrojs/sitemap": "^3.6.1",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vercel/og": "^0.8.6",
|
||||
"astro": "^5.16.8",
|
||||
"ioredis": "^5.9.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "^16.1.6",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -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>
|
||||
71
src/components/Analytics.tsx
Normal file
71
src/components/Analytics.tsx
Normal 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;
|
||||
};
|
||||
120
src/components/BlogPostClient.tsx
Normal file
120
src/components/BlogPostClient.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
192
src/components/FileExample.tsx
Normal file
192
src/components/FileExample.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
86
src/components/FileExamplesList.tsx
Normal file
86
src/components/FileExamplesList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
98
src/components/GenericEmbed.tsx
Normal file
98
src/components/GenericEmbed.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
src/components/InteractiveElements.tsx
Normal file
53
src/components/InteractiveElements.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
66
src/components/MediumCard.tsx
Normal file
66
src/components/MediumCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
87
src/components/Mermaid.tsx
Normal file
87
src/components/Mermaid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
33
src/components/Tag.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
51
src/components/TwitterEmbed.tsx
Normal file
51
src/components/TwitterEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
42
src/components/YouTubeEmbed.tsx
Normal file
42
src/components/YouTubeEmbed.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user