migrate to nextjs
This commit is contained in:
18
.gitignore
vendored
18
.gitignore
vendored
@@ -1,8 +1,11 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -15,6 +18,10 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
@@ -22,3 +29,14 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { FileExampleManager } from '../../data/fileExamples';
|
import { FileExampleManager } from '../../../src/data/fileExamples';
|
||||||
|
|
||||||
// Simple ZIP creation without external dependencies
|
// Simple ZIP creation without external dependencies
|
||||||
class SimpleZipCreator {
|
class SimpleZipCreator {
|
||||||
@@ -156,18 +156,15 @@ function intToLittleEndian(value: number, bytes: number): number[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { fileIds } = body;
|
const { fileIds } = body;
|
||||||
|
|
||||||
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
||||||
return new Response(
|
return NextResponse.json(
|
||||||
JSON.stringify({ error: 'fileIds array is required and must not be empty' }),
|
{ error: 'fileIds array is required and must not be empty' },
|
||||||
{
|
{ status: 400 }
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,10 +186,10 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const zipData = zipCreator.create();
|
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 ZIP file
|
||||||
return new Response(blob, {
|
return new Response(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="code-examples-${Date.now()}.zip"`,
|
'Content-Disposition': `attachment; filename="code-examples-${Date.now()}.zip"`,
|
||||||
@@ -202,51 +199,40 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ZIP download error:', error);
|
console.error('ZIP download error:', error);
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return NextResponse.json(
|
||||||
return new Response(
|
{ error: 'Failed to create zip file', details: errorMessage },
|
||||||
JSON.stringify({ error: 'Failed to create zip file', details: errorMessage }),
|
{ status: 500 }
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Also support GET for single file download
|
export async function GET(request: NextRequest) {
|
||||||
export const GET: APIRoute = async ({ url }) => {
|
|
||||||
try {
|
try {
|
||||||
const fileId = url.searchParams.get('id');
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fileId = searchParams.get('id');
|
||||||
|
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
return new Response(
|
return NextResponse.json(
|
||||||
JSON.stringify({ error: 'id parameter is required' }),
|
{ error: 'id parameter is required' },
|
||||||
{
|
{ status: 400 }
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await FileExampleManager.getFileExample(fileId);
|
const file = await FileExampleManager.getFileExample(fileId);
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return new Response(
|
return NextResponse.json(
|
||||||
JSON.stringify({ error: 'File not found' }),
|
{ error: 'File not found' },
|
||||||
{
|
{ status: 404 }
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const content = encoder.encode(file.content);
|
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: {
|
headers: {
|
||||||
'Content-Type': getMimeType(file.language),
|
'Content-Type': getMimeType(file.language),
|
||||||
'Content-Disposition': `attachment; filename="${file.filename}"`,
|
'Content-Disposition': `attachment; filename="${file.filename}"`,
|
||||||
@@ -256,16 +242,12 @@ export const GET: APIRoute = async ({ url }) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('File download error:', error);
|
console.error('File download error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
return new Response(
|
{ error: 'Failed to download file' },
|
||||||
JSON.stringify({ error: 'Failed to download file' }),
|
{ status: 500 }
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Helper function to get MIME type
|
// Helper function to get MIME type
|
||||||
function getMimeType(language: string): string {
|
function getMimeType(language: string): string {
|
||||||
@@ -283,4 +265,4 @@ function getMimeType(language: string): string {
|
|||||||
'text': 'text/plain'
|
'text': 'text/plain'
|
||||||
};
|
};
|
||||||
return mimeTypes[language] || '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",
|
"ioredis": "^5.9.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
"next": "^16.1.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -1710,6 +1711,140 @@
|
|||||||
"langium": "3.3.1"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -2204,6 +2339,15 @@
|
|||||||
"node": ">= 8.0.0"
|
"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": {
|
"node_modules/@tailwindcss/typography": {
|
||||||
"version": "0.5.19",
|
"version": "0.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
@@ -3392,6 +3536,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -6722,6 +6872,87 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/nlcst-to-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/stylis": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||||
@@ -8374,8 +8628,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -4,28 +4,24 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "next dev",
|
||||||
"build": "astro build",
|
"build": "next build",
|
||||||
"preview": "astro preview",
|
"start": "next start",
|
||||||
"astro": "astro",
|
"lint": "next lint",
|
||||||
"test": "npm run test:smoke",
|
"test": "npm run test:smoke",
|
||||||
"test:smoke": "tsx ./scripts/smoke-test.ts",
|
"test:smoke": "tsx ./scripts/smoke-test.ts",
|
||||||
"test:links": "tsx ./scripts/test-links.ts",
|
"test:links": "tsx ./scripts/test-links.ts",
|
||||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts"
|
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/ioredis": "^4.28.10",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vercel/og": "^0.8.6",
|
"@vercel/og": "^0.8.6",
|
||||||
"astro": "^5.16.8",
|
|
||||||
"ioredis": "^5.9.1",
|
"ioredis": "^5.9.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
"next": "^16.1.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^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
|
// Embed Components Index
|
||||||
// Note: Astro components are default exported, import them directly
|
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
export { default as YouTubeEmbed } from '../YouTubeEmbed.astro';
|
export { YouTubeEmbed } from '../YouTubeEmbed';
|
||||||
export { default as TwitterEmbed } from '../TwitterEmbed.astro';
|
export { TwitterEmbed } from '../TwitterEmbed';
|
||||||
export { default as GenericEmbed } from '../GenericEmbed.astro';
|
export { GenericEmbed } from '../GenericEmbed';
|
||||||
export { default as Mermaid } from '../Mermaid.astro';
|
export { Mermaid } from '../Mermaid';
|
||||||
|
|
||||||
// Type definitions for props
|
// Type definitions for props
|
||||||
export interface MermaidProps {
|
export interface MermaidProps {
|
||||||
@@ -33,4 +32,4 @@ export interface GenericEmbedProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
type?: 'video' | 'article' | 'rich';
|
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 = () => {
|
import React, { useState, useRef } from 'react';
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
const value = propValue !== undefined ? propValue : internalValue;
|
||||||
|
|
||||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const newValue = e.target.value;
|
||||||
setQuery(value);
|
if (onChange) {
|
||||||
|
onChange(newValue);
|
||||||
// Trigger search functionality
|
} else {
|
||||||
if (typeof window !== 'undefined') {
|
setInternalValue(newValue);
|
||||||
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 clearSearch = () => {
|
const clearSearch = () => {
|
||||||
setQuery('');
|
if (onChange) {
|
||||||
if (inputRef.current) {
|
onChange('');
|
||||||
inputRef.current.value = '';
|
} else {
|
||||||
inputRef.current.focus();
|
setInternalValue('');
|
||||||
}
|
}
|
||||||
|
if (inputRef.current) {
|
||||||
// Reset all posts
|
inputRef.current.focus();
|
||||||
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)';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,17 +48,18 @@ export const SearchBar: React.FC = () => {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
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 ${
|
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'
|
isFocused ? 'bg-white border-slate-300' : 'hover:border-slate-300'
|
||||||
}`}
|
}`}
|
||||||
onInput={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
aria-label="Search blog posts"
|
aria-label="Search blog posts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{query && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
onClick={clearSearch}
|
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"
|
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>
|
||||||
</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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
'./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@@ -63,6 +66,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/typography'),
|
typography,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"include": [
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"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",
|
"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