wip
This commit is contained in:
@@ -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;
|
||||
}
|
||||
`;
|
||||
30
src/components/Embeds/index.ts
Normal file
30
src/components/Embeds/index.ts
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
204
src/components/GenericEmbed.astro
Normal file
204
src/components/GenericEmbed.astro
Normal 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>
|
||||
65
src/components/TwitterEmbed.astro
Normal file
65
src/components/TwitterEmbed.astro
Normal 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>
|
||||
32
src/components/YouTubeEmbed.astro
Normal file
32
src/components/YouTubeEmbed.astro
Normal 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>
|
||||
@@ -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
28
src/data/embedDemoPost.ts
Normal 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"
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
478
src/pages/blog/embed-demo.astro
Normal file
478
src/pages/blog/embed-demo.astro
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user