This commit is contained in:
2026-01-14 17:15:10 +01:00
parent 172e2600d1
commit a9b2c89636
20 changed files with 2093 additions and 158 deletions

View File

@@ -1,4 +1,18 @@
import React from 'react';
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 BlockquoteProps {
children: React.ReactNode;
@@ -12,89 +26,183 @@ export const Blockquote: React.FC<BlockquoteProps> = ({ children, className = ''
);
interface CodeBlockProps {
children: string;
language?: string;
showLineNumbers?: boolean;
className?: string;
}
code?: string;
children?: React.ReactNode;
language?: string;
showLineNumbers?: boolean;
className?: string;
}
// Simple syntax highlighting for common languages
const highlightCode = (code: string, language: string): string => {
code = code.trim();
const patterns: Record<string, RegExp[]> = {
comment: [/#[^\n]*/g, /\/\/[^\n]*/g, /\/\*[\s\S]*?\*\//g],
string: [/["'`][^"'`]*["'`]/g],
number: [/\b\d+\b/g],
keyword: [
/\b(def|return|if|else|for|while|import|from|class|try|except|with|as|lambda|yield|async|await|pass|break|continue)\b/g,
/\b(function|const|let|var|if|else|for|while|return|import|export|class|new|try|catch|finally)\b/g,
/\b(package|import|class|public|private|protected|static|void|return|if|else|for|while|try|catch)\b/g
],
function: [/\b[a-zA-Z_]\w*(?=\s*\()/g],
operator: [/[\+\-\*\/=<>!&|]+/g],
punctuation: [/[\[\]{}(),;.:]/g],
tag: [/<\/?[a-zA-Z][^>]*>/g],
attr: [/\b[a-zA-Z-]+(?=\=)/g],
attrValue: [/="[^"]*"/g],
};
let highlighted = code;
const order = ['comment', 'string', 'number', 'keyword', 'function', 'operator', 'punctuation', 'tag', 'attr', 'attrValue'];
order.forEach(type => {
patterns[type].forEach(pattern => {
highlighted = highlighted.replace(pattern, match => {
return `<span class="token ${type}">${match}</span>`;
});
});
});
return highlighted;
// Language mapping for Prism.js
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',
astro: 'markup', // Fallback for Astro
};
export const CodeBlock: React.FC<CodeBlockProps> = ({
children,
language = 'text',
showLineNumbers = false,
className = ''
}) => {
const code = (typeof children === 'string' ? children : String(children)).trim();
const highlighted = language !== 'text' ? highlightCode(code, language) : code;
const lines = code.split('\n');
// Highlight code using Prism.js
const highlightCode = (code: string, language: string): { html: string; prismLanguage: string } => {
const prismLanguage = prismLanguageMap[language] || language || 'markup';
return (
<div className="relative my-6">
{language !== 'text' && (
<div className="absolute top-2 right-2 text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded font-sans">
{language}
</div>
)}
<pre
className={`bg-slate-900 text-slate-100 p-5 rounded-lg overflow-x-auto border border-slate-700 font-mono text-sm leading-relaxed ${className} ${showLineNumbers ? 'pl-12' : ''}`}
>
{showLineNumbers ? (
<div className="relative">
<div className="absolute left-0 top-0 bottom-0 w-8 text-right text-slate-600 select-none pr-3 border-r border-slate-700">
{lines.map((_, i) => (
<div key={i} className="leading-relaxed">{i + 1}</div>
))}
</div>
<div className="pl-10">
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</div>
</div>
) : (
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
)}
</pre>
</div>
);
};
try {
const highlighted = Prism.highlight(
code.trim(),
Prism.languages[prismLanguage] || Prism.languages.markup,
prismLanguage,
);
return { html: highlighted, prismLanguage };
} catch (error) {
console.warn('Prism highlighting failed:', error);
return { html: code.trim(), prismLanguage: 'text' };
}
};
export const CodeBlock: React.FC<CodeBlockProps> = ({
code,
children,
language = 'text',
showLineNumbers = false,
className = ''
}) => {
const codeContent = code || (typeof children === 'string' ? children : String(children || '')).trim();
const { html: highlightedCode, prismLanguage } = language !== 'text' ? highlightCode(codeContent, language) : { html: codeContent, prismLanguage: 'text' };
const lines = codeContent.split('\n');
return (
<>
<style dangerouslySetInnerHTML={{ __html: syntaxHighlightingStyles }} />
<div className="relative my-6">
{language !== 'text' && (
<div className="absolute top-2 right-2 text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded font-sans z-10 border border-slate-200">
{language}
</div>
)}
<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 ${className} ${showLineNumbers ? 'pl-12' : ''}`}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", maxHeight: "22rem" }}
>
{showLineNumbers ? (
<div className="relative">
<div className="absolute left-0 top-0 bottom-0 w-8 text-right text-slate-600 select-none pr-3 border-r border-slate-700">
{lines.map((_, i) => (
<div key={i} className="leading-relaxed">{i + 1}</div>
))}
</div>
<div className="pl-10">
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
</div>
</div>
) : (
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
)}
</pre>
</div>
</>
);
};
export const InlineCode: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
<code className={`bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded font-mono text-sm border border-pink-200 ${className}`}>
{children}
</code>
);
);
// Prism.js syntax highlighting styles (matching FileExample.astro)
const syntaxHighlightingStyles = `
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;
}
`;

View File

@@ -0,0 +1,30 @@
// Embed Components Index
// Note: Astro components are default exported, import them directly
// Re-export for convenience
export { default as YouTubeEmbed } from '../YouTubeEmbed.astro';
export { default as TwitterEmbed } from '../TwitterEmbed.astro';
export { default as GenericEmbed } from '../GenericEmbed.astro';
// Type definitions for props
export interface YouTubeEmbedProps {
videoId: string;
title?: string;
className?: string;
aspectRatio?: string;
style?: 'default' | 'minimal' | 'rounded' | 'flat';
}
export interface TwitterEmbedProps {
tweetId: string;
theme?: 'light' | 'dark';
className?: string;
align?: 'left' | 'center' | 'right';
}
export interface GenericEmbedProps {
url: string;
className?: string;
maxWidth?: string;
type?: 'video' | 'article' | 'rich';
}

View File

@@ -135,7 +135,7 @@ const highlightedCode = Prism.highlight(
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"
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>
@@ -254,7 +254,8 @@ const highlightedCode = Prism.highlight(
/* Prism.js syntax highlighting - light, low-noise */
code[class*='language-'],
pre[class*='language-'] {
pre[class*='language-'],
pre:has(code[class*='language-']) {
color: #0f172a;
background: transparent;
text-shadow: none;

View File

@@ -0,0 +1,204 @@
---
// GenericEmbed.astro - Universal embed component using direct iframes
interface Props {
url: string;
className?: string;
maxWidth?: string;
type?: 'video' | 'article' | 'rich';
}
const {
url,
className = "",
maxWidth = "100%",
type = 'rich'
} = Astro.props;
// Detect provider and create direct embed URLs
let embedUrl: string | null = null;
let provider = 'unknown';
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.replace('www.', '');
// YouTube
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
const videoId = urlObj.searchParams.get('v') || urlObj.pathname.split('/').pop();
if (videoId) {
embedUrl = `https://www.youtube.com/embed/${videoId}`;
provider = 'youtube.com';
}
}
// Vimeo
else if (hostname.includes('vimeo.com')) {
const videoId = urlObj.pathname.split('/').filter(Boolean)[0];
if (videoId) {
embedUrl = `https://player.vimeo.com/video/${videoId}`;
provider = 'vimeo.com';
}
}
// CodePen
else if (hostname.includes('codepen.io')) {
const penPath = urlObj.pathname.replace('/pen/', '/');
embedUrl = `https://codepen.io${penPath}?default-tab=html,result`;
provider = 'codepen.io';
}
// GitHub Gist
else if (hostname.includes('gist.github.com')) {
const gistPath = urlObj.pathname;
embedUrl = `https://gist.github.com${gistPath}.js`;
provider = 'gist.github.com';
}
} catch (e) {
console.warn('GenericEmbed: Failed to parse URL', e);
}
// Fallback to simple link
const hasEmbed = embedUrl !== null;
---
<div
class={`generic-embed not-prose ${className}`}
data-provider={provider}
data-type={type}
style={`--max-width: ${maxWidth};`}
>
{hasEmbed ? (
<div class="embed-wrapper">
{type === 'video' ? (
<iframe
src={embedUrl}
width="100%"
height="100%"
style="border: none; width: 100%; height: 100%;"
allowfullscreen
loading="lazy"
/>
) : (
<iframe
src={embedUrl}
width="100%"
height="400"
style="border: none;"
loading="lazy"
/>
)}
</div>
) : (
<div class="embed-fallback">
<div class="fallback-content">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Unable to embed this URL</span>
<a href={url} target="_blank" rel="noopener noreferrer" class="fallback-link">
Open link →
</a>
</div>
</div>
)}
</div>
<style>
.generic-embed {
--max-width: 100%;
--border-radius: 8px;
--bg-color: #ffffff;
--border-color: #e2e8f0;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
margin: 1.5rem 0;
width: 100%;
max-width: var(--max-width);
}
.embed-wrapper {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background: var(--bg-color);
box-shadow: var(--shadow);
overflow: hidden;
transition: all 0.2s ease;
position: relative;
}
/* Video type gets aspect ratio */
.generic-embed[data-type="video"] .embed-wrapper {
aspect-ratio: 16/9;
height: 0;
padding-bottom: 56.25%; /* 16:9 */
}
.generic-embed[data-type="video"] .embed-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.embed-wrapper:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border-color: #cbd5e1;
}
/* Provider-specific styling */
.generic-embed[data-provider="youtube.com"] {
--bg-color: #000000;
}
.generic-embed[data-provider="vimeo.com"] {
--bg-color: #1a1a1a;
}
.generic-embed[data-provider="codepen.io"] {
--bg-color: #1e1e1e;
--border-color: #333;
}
/* Fallback styling */
.embed-fallback {
padding: 1.5rem;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: var(--border-radius);
text-align: center;
}
.fallback-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: #64748b;
font-size: 0.875rem;
}
.fallback-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
margin-top: 0.25rem;
word-break: break-all;
}
.fallback-link:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 768px) {
.generic-embed {
margin: 1rem 0;
}
.embed-fallback {
padding: 1rem;
}
.embed-wrapper:hover {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
}
</style>

View File

@@ -0,0 +1,65 @@
---
// TwitterEmbed.astro - Build-time Twitter embed using oEmbed API
interface Props {
tweetId: string;
theme?: 'light' | 'dark';
className?: string;
align?: 'left' | 'center' | 'right';
}
const {
tweetId,
theme = 'light',
className = "",
align = 'center'
} = Astro.props;
// Fetch tweet data at build time using Twitter oEmbed API
let embedHtml = '';
let fallbackHtml = '';
try {
const oEmbedUrl = `https://publish.twitter.com/oembed?url=https://twitter.com/twitter/status/${tweetId}&theme=${theme}`;
const response = await fetch(oEmbedUrl);
if (response.ok) {
const data = await response.json();
embedHtml = data.html || '';
} else {
console.warn(`Twitter oEmbed failed for tweet ${tweetId}: ${response.status}`);
}
} catch (error) {
console.warn(`Failed to fetch Twitter embed for ${tweetId}:`, error);
}
// Fallback HTML if oEmbed fails
if (!embedHtml) {
fallbackHtml = `
<div class="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href="https://twitter.com/i/status/${tweetId}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 font-medium text-sm">
View on Twitter →
</a>
</div>
`;
}
---
<div class={`not-prose ${className} ${align === 'left' ? 'mr-auto ml-0' : align === 'right' ? 'ml-auto mr-0' : 'mx-auto'} w-4/5`} data-theme={theme} data-align={align}>
{embedHtml ? (
<div set:html={embedHtml} />
) : (
<div class="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href="https://twitter.com/i/status/20" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 font-medium text-sm">
View on Twitter →
</a>
</div>
)}
</div>

View File

@@ -0,0 +1,32 @@
---
// YouTubeEmbed.astro - Build-time component with full styling control
interface Props {
videoId: string;
title?: string;
className?: string;
aspectRatio?: string;
style?: 'default' | 'minimal' | 'rounded' | 'flat';
}
const {
videoId,
title = "YouTube Video",
className = "",
aspectRatio = "56.25%",
style = "default"
} = Astro.props;
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
---
<div class={`not-prose my-6 ${className}`} data-style={style}>
<div class="bg-white border border-slate-200/80 rounded-xl overflow-hidden transition-all duration-200 hover:border-slate-300/80 p-1" style={`padding-bottom: calc(${aspectRatio} - 0.5rem); position: relative; height: 0; margin-top: 0.25rem; margin-bottom: 0.25rem;`}>
<iframe
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="absolute top-1 left-1 w-[calc(100%-0.5rem)] h-[calc(100%-0.5rem)] border-none rounded-md"
/>
</div>
</div>

View File

@@ -34,5 +34,12 @@ export const blogPosts: BlogPost[] = [
date: "2024-02-10",
slug: "docker-deployment",
tags: ["docker", "deployment", "architecture"]
},
{
title: "Rich Content Embedding Demo",
description: "Testing our new free embed components for YouTube, Twitter, and other platforms",
date: "2024-02-15",
slug: "embed-demo",
tags: ["embeds", "components", "tutorial"]
}
];

28
src/data/embedDemoPost.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { BlogPost } from './blogPosts';
export const embedDemoPost: BlogPost = {
title: "Rich Content Embedding Demo",
description: "Testing our new free embed components for YouTube, Twitter, and other platforms",
date: "2024-02-15",
slug: "embed-demo",
tags: ["embeds", "components", "tutorial"]
};
// This would be used in your blog post template to demonstrate the components
export const embedDemoContent = {
youtube: {
videoId: "dQw4w9WgXcQ", // Replace with actual video ID
title: "Demo Video",
style: "minimal"
},
twitter: {
tweetId: "1234567890123456789", // Replace with actual tweet ID
theme: "dark",
align: "center"
},
generic: {
url: "https://vimeo.com/123456789", // Replace with actual URL
type: "video",
maxWidth: "800px"
}
};

View File

@@ -4,6 +4,21 @@ 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;

View File

@@ -27,9 +27,6 @@ const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
const wordCount = post.description.split(/\s+/).length + 100;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
// Generate unique clap key for this post
const clapKey = `claps_${post.slug}`;
// Determine if this post should show file examples
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
@@ -64,17 +61,6 @@ const showFileExamples = post.tags?.some(tag =>
<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>
<button
id="clap-btn-top"
class="clap-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="Clap for this post"
data-clap-key={clapKey}
>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.789l5 2.5a2 2 0 001.794 0l5-2.5A2 2 0 0019 15.762v-5.43a2.5 2.5 0 00-1.292-2.182l-4.4-2.2a1.5 1.5 0 00-1.333 0l-4.4 2.2A2.5 2.5 0 002 10.333z"/>
</svg>
<span id="clap-count-top" class="text-sm font-medium text-slate-700">0</span>
</button>
</div>
</div>
</nav>
@@ -378,47 +364,6 @@ const showFileExamples = post.tags?.some(tag =>
}
}
// Production-ready clap functionality with localStorage
function setupClapButtons() {
const clapBtnTop = document.getElementById('clap-btn-top');
const clapCountTop = document.getElementById('clap-count-top');
if (!clapBtnTop || !clapCountTop) return;
// Get clap key from data attribute
const clapKey = clapBtnTop.getAttribute('data-clap-key');
if (!clapKey) return;
// Load existing claps from localStorage
let claps = parseInt(localStorage.getItem(clapKey) || '0');
clapCountTop.textContent = claps.toString();
// Visual state if already clapped
if (claps > 0) {
clapBtnTop.classList.add('bg-blue-50', 'border-blue-300');
}
clapBtnTop.addEventListener('click', () => {
// Increment claps
claps++;
localStorage.setItem(clapKey, claps.toString());
clapCountTop.textContent = claps.toString();
// Visual feedback
clapBtnTop.classList.add('scale-110', 'bg-blue-100', 'border-blue-300');
setTimeout(() => {
clapBtnTop.classList.remove('scale-110', 'bg-blue-100', 'border-blue-300');
}, 300);
// Ripple effect
const ripple = document.createElement('span');
ripple.className = 'absolute inset-0 bg-blue-400/20 rounded-full scale-0 animate-ripple';
clapBtnTop.style.position = 'relative';
clapBtnTop.style.overflow = 'hidden';
clapBtnTop.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
}
// Share function - Web Share API or modal with share links
function setupShareButton() {
@@ -613,9 +558,8 @@ const showFileExamples = post.tags?.some(tag =>
updateReadingProgress();
updateBackToTop();
setupBackNavigation();
setupClapButtons();
setupShareButton();
window.addEventListener('scroll', () => {
updateReadingProgress();
updateBackToTop();
@@ -693,7 +637,6 @@ const showFileExamples = post.tags?.some(tag =>
/* Focus styles for all interactive elements */
a:focus,
button:focus,
.clap-button-top:focus,
.share-button-top:focus,
#back-btn-top:focus,
#back-btn-bottom:focus,
@@ -727,23 +670,6 @@ const showFileExamples = post.tags?.some(tag =>
transform: translateY(-2px) scale(1.05);
}
/* Clap button animations */
.clap-button-top {
position: relative;
overflow: hidden;
}
/* Ripple animation */
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
.animate-ripple {
animation: ripple 0.6s ease-out;
}
/* Smooth scroll */
html {

View File

@@ -0,0 +1,478 @@
---
// 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';
const post = {
title: "Rich Content Embedding Demo",
description: "Testing our new free embed components for YouTube, Twitter, and other platforms",
date: "2024-02-15",
slug: "embed-demo",
tags: ["embeds", "components", "tutorial"]
};
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>Styling Control</H2>
<Paragraph>
All components use CSS variables for easy customization:
</Paragraph>
<CodeBlock
language="css"
showLineNumbers={true}
code={`.youtube-embed {
--aspect-ratio: 56.25%;
--bg-color: #000000;
--border-radius: 12px;
--shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Data attribute variations */
.youtube-embed[data-style="minimal"] {
--border-radius: 4px;
--shadow: none;
}`}
/>
<H2>Benefits</H2>
<UL>
<LI><strong>Free:</strong> No paid services required</LI>
<LI><strong>Fast:</strong> Build-time generation, no runtime API calls</LI>
<LI><strong>Flexible:</strong> Full styling control via CSS variables</LI>
<LI><strong>Self-hosted:</strong> Complete ownership and privacy</LI>
<LI><strong>SEO-friendly:</strong> Static HTML content</LI>
</UL>
<H2>Integration</H2>
<Paragraph>
Simply import the components in your blog posts:
</Paragraph>
<CodeBlock
language="astro"
showLineNumbers={true}
code={`---
import YouTubeEmbed from '../components/YouTubeEmbed.astro';
import TwitterEmbed from '../components/TwitterEmbed.astro';
import GenericEmbed from '../components/GenericEmbed.astro';
---
<YouTubeEmbed videoId="abc123" style="rounded" />
<TwitterEmbed tweetId="123456789" theme="dark" />`}
/>
</div>
<!-- Footer -->
<div class="mt-16 pt-8 border-t border-slate-200 text-center">
<button
id="back-btn-bottom"
class="inline-flex items-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-full hover:border-blue-400 hover:text-blue-600 hover:shadow-md transition-all duration-300 font-medium group"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</button>
</div>
</section>
</main>
<script>
// Reading progress bar
function updateReadingProgress() {
const section = document.querySelector('section:last-child') as HTMLElement;
const progressBar = document.getElementById('reading-progress');
if (!section || !progressBar) return;
const sectionTop = section.offsetTop;
const sectionHeight = section.offsetHeight;
const windowHeight = window.innerHeight;
const scrollTop = window.scrollY;
const progress = Math.min(
Math.max((scrollTop - sectionTop + windowHeight * 0.3) / (sectionHeight - windowHeight * 0.3), 0),
1
);
progressBar.style.width = `${progress * 100}%`;
const topNav = document.getElementById('top-nav');
if (topNav) {
if (scrollTop > 100) {
topNav.style.backdropFilter = 'blur(12px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
} else {
topNav.style.backdropFilter = 'blur(8px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.8)';
}
}
}
// Back navigation
function setupBackNavigation() {
const backButtons = [
document.getElementById('back-btn-top'),
document.getElementById('back-btn-bottom')
];
const goHome = () => {
const content = document.getElementById('post-content');
if (content) {
content.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
content.style.opacity = '0';
content.style.transform = 'translateY(20px) scale(0.98)';
}
const topNav = document.getElementById('top-nav');
if (topNav) {
topNav.style.transition = 'opacity 0.4s ease-out';
topNav.style.opacity = '0';
}
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 bg-gradient-to-br from-white via-slate-50 to-white z-[100] opacity-0 transition-opacity duration-500';
document.body.appendChild(overlay);
setTimeout(() => {
overlay.style.opacity = '1';
}, 100);
setTimeout(() => {
window.location.href = '/?from=post';
}, 500);
};
backButtons.forEach(btn => {
if (btn) btn.addEventListener('click', goHome);
});
}
// Share functionality
function setupShareButton() {
const shareBtn = document.getElementById('share-btn-top');
if (!shareBtn) return;
const url = window.location.href;
const title = document.title;
if (navigator.share) {
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({ title, url });
shareBtn.classList.add('bg-green-50', 'border-green-300');
setTimeout(() => {
shareBtn.classList.remove('bg-green-50', 'border-green-300');
}, 1000);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
updateReadingProgress();
setupBackNavigation();
setupShareButton();
window.addEventListener('scroll', updateReadingProgress);
});
</script>
<style>
/* Enhanced typography */
.prose-slate {
color: #334155;
}
.prose-slate p {
margin-bottom: 1.75rem;
line-height: 1.85;
font-size: 1.125rem;
}
.prose-slate h2 {
margin-top: 2.75rem;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.prose-slate h3 {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.prose-slate ul,
.prose-slate ol {
margin-bottom: 1.75rem;
padding-left: 1.5rem;
}
.prose-slate li {
margin-bottom: 0.55rem;
line-height: 1.75;
}
.prose-slate blockquote {
border-left: 4px solid #cbd5e1;
padding-left: 1.5rem;
font-style: italic;
color: #475569;
margin: 1.75rem 0;
font-size: 1.125rem;
background: linear-gradient(to right, #f8fafc, #ffffff);
padding: 1rem 1.5rem 1rem 1.5rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.prose-slate code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
color: #dc2626;
font-family: 'JetBrains Mono', monospace;
}
/* Smooth transitions */
a, button {
transition: all 0.2s ease;
}
/* Focus styles */
a:focus,
button:focus,
.share-button-top:focus,
#back-btn-top:focus,
#back-btn-bottom:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* Top nav transitions */
#top-nav {
transition: backdrop-filter 0.3s ease, background-color 0.3s ease, opacity 0.4s ease;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #94a3b8, #64748b);
}
/* Selection styling */
::selection {
background: linear-gradient(to right, #3b82f6, #8b5cf6);
color: white;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</BaseLayout>

View File

@@ -64,7 +64,7 @@
@apply mb-1;
}
code {
code:not([class*='language-']) {
@apply bg-slate-100 px-1 py-0.5 rounded font-mono text-sm text-slate-700;
}