wip
This commit is contained in:
336
src/components/FileExample.astro
Normal file
336
src/components/FileExample.astro
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
// 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"
|
||||
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-'] {
|
||||
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>
|
||||
150
src/components/FileExamplesList.astro
Normal file
150
src/components/FileExamplesList.astro
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
// 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>
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
import Tag from './Tag.astro';
|
||||
|
||||
interface Props {
|
||||
post: {
|
||||
title: string;
|
||||
@@ -12,41 +10,41 @@ interface Props {
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { title, description, date, slug, tags = [] } = post;
|
||||
const { title, description, date, tags = [] } = post;
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
// Calculate reading time
|
||||
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 rounded-xl p-5 hover:border-blue-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h3 class="text-lg font-serif font-medium text-slate-900 hover:text-blue-600 transition-colors leading-tight flex-1 mr-3">
|
||||
{title}
|
||||
<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>
|
||||
<span class="text-xs text-slate-500 font-sans whitespace-nowrap flex-shrink-0">
|
||||
<time class="text-[11px] text-slate-500 tabular-nums whitespace-nowrap leading-none pt-0.5">
|
||||
{formattedDate}
|
||||
</span>
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-600 leading-relaxed font-serif text-sm mb-3 line-clamp-3">
|
||||
<p class="post-excerpt mt-2 mb-0 text-[13px] leading-relaxed text-slate-600 line-clamp-3">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500 font-sans">
|
||||
{readingTime} min
|
||||
</span>
|
||||
<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 gap-1">
|
||||
{tags.map((tag: string) => (
|
||||
<span class="bg-slate-100 text-slate-700 px-2 py-0.5 rounded text-[10px] font-medium">
|
||||
<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>
|
||||
))}
|
||||
@@ -55,76 +53,72 @@ const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
/* Simple reveal animation */
|
||||
@keyframes cardReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.post-card {
|
||||
animation: cardReveal 0.3s ease-out both;
|
||||
}
|
||||
|
||||
/* Stagger for multiple cards */
|
||||
.post-card:nth-child(1) { animation-delay: 0.05s; }
|
||||
.post-card:nth-child(2) { animation-delay: 0.1s; }
|
||||
.post-card:nth-child(3) { animation-delay: 0.15s; }
|
||||
.post-card:nth-child(4) { animation-delay: 0.2s; }
|
||||
.post-card:nth-child(5) { animation-delay: 0.25s; }
|
||||
.post-card:nth-child(6) { animation-delay: 0.3s; }
|
||||
|
||||
/* Clean hover effects */
|
||||
.post-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Title underline on hover */
|
||||
.post-card h3 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.post-card h3::after {
|
||||
<style is:global>
|
||||
.marker-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: #3b82f6;
|
||||
transition: width 0.2s ease;
|
||||
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);
|
||||
}
|
||||
|
||||
.post-card:hover h3::after {
|
||||
width: 100%;
|
||||
.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%
|
||||
);
|
||||
}
|
||||
|
||||
/* Line clamp */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
.post-card:focus-within {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.post-card h3:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -68,19 +68,14 @@ export const SearchBar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-2xl mx-auto">
|
||||
<div className={`relative flex items-center transition-all duration-300 ${
|
||||
isFocused ? 'scale-[1.02]' : 'scale-100'
|
||||
}`}>
|
||||
{/* Search input wrapper */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search posts, topics, or tags..."
|
||||
className={`w-full px-5 py-4 pl-12 text-base border-2 rounded-xl transition-all duration-300 font-sans focus:outline-none ${
|
||||
isFocused
|
||||
? 'border-blue-400 bg-white shadow-lg'
|
||||
: 'border-slate-200 bg-white/90 hover:border-slate-300'
|
||||
placeholder="Search"
|
||||
className={`w-full px-3 py-2 text-[14px] border border-slate-200 rounded-md bg-transparent transition-colors font-sans focus:outline-none ${
|
||||
isFocused ? 'bg-white border-slate-300' : 'hover:border-slate-300'
|
||||
}`}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -88,54 +83,23 @@ export const SearchBar: React.FC = () => {
|
||||
onBlur={() => setIsFocused(false)}
|
||||
aria-label="Search blog posts"
|
||||
/>
|
||||
|
||||
{/* Search icon */}
|
||||
<div className={`absolute left-4 top-1/2 -translate-y-1/2 transition-all duration-300 ${
|
||||
isFocused ? 'text-blue-600 scale-110' : 'text-slate-400'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Clear button */}
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-slate-100 transition-colors duration-200 text-slate-500 hover:text-slate-700"
|
||||
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"
|
||||
aria-label="Clear search"
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search hint */}
|
||||
<div className="hidden md:flex items-center gap-2 ml-3 text-xs text-slate-500 font-sans">
|
||||
<div className="hidden md:flex items-center gap-2 ml-3 text-xs text-slate-400 font-sans">
|
||||
<kbd className="bg-slate-100 px-2 py-1 rounded border border-slate-200">ESC</kbd>
|
||||
<span>to clear</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filter indicator */}
|
||||
{query && (
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">
|
||||
Searching for: <span className="font-semibold text-blue-600">"{query}"</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="text-slate-500 hover:text-blue-600 transition-colors duration-200 font-medium"
|
||||
>
|
||||
Show all posts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count (hidden by default, shown via JS) */}
|
||||
<div id="search-results-count" className="mt-2 text-sm text-slate-500 font-sans hidden"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,12 +22,6 @@ 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>
|
||||
|
||||
<!-- Subtle underline animation -->
|
||||
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-current transition-all duration-300 group-hover:w-full opacity-50"></span>
|
||||
|
||||
<!-- Micro-interaction: small dot that appears on hover -->
|
||||
<span class="absolute top-1/2 left-1/2 w-0 h-0 bg-white/30 rounded-full transform -translate-x-1/2 -translate-y-1/2 transition-all duration-300 group-hover:w-1 group-hover:h-1"></span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -20,5 +20,19 @@ export const blogPosts: BlogPost[] = [
|
||||
date: "2024-01-20",
|
||||
slug: "debugging-tips",
|
||||
tags: ["debugging", "tools"]
|
||||
},
|
||||
{
|
||||
title: "Software Architecture Patterns",
|
||||
description: "Exploring common architectural patterns for scalable systems",
|
||||
date: "2024-02-01",
|
||||
slug: "architecture-patterns",
|
||||
tags: ["architecture", "design-patterns", "system-design"]
|
||||
},
|
||||
{
|
||||
title: "Docker Deployment Best Practices",
|
||||
description: "Production-ready Docker configurations and workflows",
|
||||
date: "2024-02-10",
|
||||
slug: "docker-deployment",
|
||||
tags: ["docker", "deployment", "architecture"]
|
||||
}
|
||||
];
|
||||
628
src/data/fileExamples.ts
Normal file
628
src/data/fileExamples.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* File Examples Data Structure
|
||||
*
|
||||
* This module manages file examples for blog posts.
|
||||
* Each example includes the file content, metadata, and can be easily copied or downloaded.
|
||||
*/
|
||||
|
||||
export interface FileExample {
|
||||
id: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
language: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
postSlug?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FileExampleGroup {
|
||||
groupId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
files: FileExample[];
|
||||
}
|
||||
|
||||
// In-memory storage (for development)
|
||||
// In production, this could be backed by a database or file system
|
||||
const fileExamplesStore = new Map<string, FileExample>();
|
||||
|
||||
// Sample file examples for demonstration
|
||||
export const sampleFileExamples: FileExampleGroup[] = [
|
||||
{
|
||||
groupId: "python-data-processing",
|
||||
title: "Python Data Processing Example",
|
||||
description: "A complete example of processing data with error handling",
|
||||
files: [
|
||||
{
|
||||
id: "python-data-processor",
|
||||
filename: "data_processor.py",
|
||||
content: `import json
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DataProcessor:
|
||||
def __init__(self, input_path: str, output_path: str):
|
||||
self.input_path = Path(input_path)
|
||||
self.output_path = Path(output_path)
|
||||
|
||||
def load_data(self) -> List[Dict[str, Any]]:
|
||||
"""Load JSON data from input file."""
|
||||
if not self.input_path.exists():
|
||||
raise FileNotFoundError(f"Input file not found: {self.input_path}")
|
||||
|
||||
with open(self.input_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
logger.info(f"Loaded {len(data)} records")
|
||||
return data
|
||||
|
||||
def process_records(self, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Process records and add computed fields."""
|
||||
processed = []
|
||||
for record in data:
|
||||
# Add timestamp
|
||||
import time
|
||||
record['processed_at'] = time.time()
|
||||
|
||||
# Normalize keys
|
||||
record['id'] = record.get('id', '').lower()
|
||||
|
||||
processed.append(record)
|
||||
|
||||
logger.info(f"Processed {len(processed)} records")
|
||||
return processed
|
||||
|
||||
def save_data(self, data: List[Dict[str, Any]]) -> None:
|
||||
"""Save processed data to output file."""
|
||||
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.info(f"Saved {len(data)} records to {self.output_path}")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Execute the complete processing pipeline."""
|
||||
try:
|
||||
data = self.load_data()
|
||||
processed = self.process_records(data)
|
||||
self.save_data(processed)
|
||||
logger.info("Processing completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Processing failed: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
processor = DataProcessor(
|
||||
input_path="data/input.json",
|
||||
output_path="data/processed.json"
|
||||
)
|
||||
processor.run()`,
|
||||
language: "python",
|
||||
description: "A robust data processor with logging and error handling",
|
||||
tags: ["python", "data-processing", "logging"],
|
||||
postSlug: "debugging-tips",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: "python-config-example",
|
||||
filename: "config.py",
|
||||
content: `"""
|
||||
Configuration management for the data processor
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Configuration for data processing."""
|
||||
|
||||
input_path: str
|
||||
output_path: str
|
||||
batch_size: int = 1000
|
||||
max_workers: int = 4
|
||||
enable_caching: bool = True
|
||||
log_level: str = "INFO"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'Config':
|
||||
"""Create config from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
'input_path': self.input_path,
|
||||
'output_path': self.output_path,
|
||||
'batch_size': self.batch_size,
|
||||
'max_workers': self.max_workers,
|
||||
'enable_caching': self.enable_caching,
|
||||
'log_level': self.log_level
|
||||
}
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG = Config(
|
||||
input_path="data/input.json",
|
||||
output_path="data/output.json",
|
||||
batch_size=500,
|
||||
max_workers=2
|
||||
)`,
|
||||
language: "python",
|
||||
description: "Configuration management using dataclasses",
|
||||
tags: ["python", "configuration", "dataclasses"],
|
||||
postSlug: "debugging-tips",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
groupId: "typescript-architecture",
|
||||
title: "TypeScript Architecture Patterns",
|
||||
description: "Modern TypeScript patterns for scalable applications",
|
||||
files: [
|
||||
{
|
||||
id: "ts-interface-example",
|
||||
filename: "interfaces.ts",
|
||||
content: `/**
|
||||
* Core interfaces for a scalable TypeScript application
|
||||
*/
|
||||
|
||||
// Repository pattern
|
||||
export interface Repository<T> {
|
||||
findById(id: string): Promise<T | null>;
|
||||
findAll(): Promise<T[]>;
|
||||
create(entity: Omit<T, 'id'>): Promise<T>;
|
||||
update(id: string, entity: Partial<T>): Promise<T>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// Service layer interface
|
||||
export interface Service<T> {
|
||||
get(id: string): Promise<T>;
|
||||
list(): Promise<T[]>;
|
||||
create(data: any): Promise<T>;
|
||||
update(id: string, data: any): Promise<T>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Event system
|
||||
export interface DomainEvent {
|
||||
type: string;
|
||||
payload: any;
|
||||
timestamp: Date;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface EventHandler {
|
||||
handle(event: DomainEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface EventPublisher {
|
||||
publish(event: DomainEvent): Promise<void>;
|
||||
subscribe(handler: EventHandler): void;
|
||||
}
|
||||
|
||||
// Result type for error handling
|
||||
export type Result<T, E = Error> =
|
||||
| { success: true; value: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
export namespace Result {
|
||||
export function ok<T>(value: T): Result<T> {
|
||||
return { success: true, value };
|
||||
}
|
||||
|
||||
export function fail<E extends Error>(error: E): Result<never, E> {
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
export function isOk<T, E>(result: Result<T, E>): result is { success: true; value: T } {
|
||||
return result.success;
|
||||
}
|
||||
|
||||
export function isFail<T, E>(result: Result<T, E>): result is { success: false; error: E } {
|
||||
return !result.success;
|
||||
}
|
||||
}`,
|
||||
language: "typescript",
|
||||
description: "TypeScript interfaces for clean architecture",
|
||||
tags: ["typescript", "architecture", "interfaces"],
|
||||
postSlug: "architecture-patterns",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: "ts-service-example",
|
||||
filename: "userService.ts",
|
||||
content: `import { Repository, Service, Result, DomainEvent, EventPublisher } from './interfaces';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface CreateUserDTO {
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
class UserService implements Service<User> {
|
||||
constructor(
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly eventPublisher: EventPublisher
|
||||
) {}
|
||||
|
||||
async get(id: string): Promise<User> {
|
||||
const user = await this.userRepository.findById(id);
|
||||
if (!user) {
|
||||
throw new Error(\`User with id \${id} not found\`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async list(): Promise<User[]> {
|
||||
return this.userRepository.findAll();
|
||||
}
|
||||
|
||||
async create(data: CreateUserDTO): Promise<User> {
|
||||
// Validate email
|
||||
if (!this.isValidEmail(data.email)) {
|
||||
throw new Error('Invalid email format');
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await this.userRepository.create({
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
// Publish event
|
||||
const event: DomainEvent = {
|
||||
type: 'USER_CREATED',
|
||||
payload: { userId: user.id, email: user.email },
|
||||
timestamp: new Date(),
|
||||
source: 'UserService'
|
||||
};
|
||||
|
||||
await this.eventPublisher.publish(event);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<User>): Promise<User> {
|
||||
const existing = await this.get(id);
|
||||
const updated = await this.userRepository.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
const event: DomainEvent = {
|
||||
type: 'USER_UPDATED',
|
||||
payload: { userId: id, changes: data },
|
||||
timestamp: new Date(),
|
||||
source: 'UserService'
|
||||
};
|
||||
|
||||
await this.eventPublisher.publish(event);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const success = await this.userRepository.delete(id);
|
||||
if (!success) {
|
||||
throw new Error(\`Failed to delete user \${id}\`);
|
||||
}
|
||||
|
||||
const event: DomainEvent = {
|
||||
type: 'USER_DELETED',
|
||||
payload: { userId: id },
|
||||
timestamp: new Date(),
|
||||
source: 'UserService'
|
||||
};
|
||||
|
||||
await this.eventPublisher.publish(event);
|
||||
}
|
||||
|
||||
private isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// Additional business logic
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const users = await this.userRepository.findAll();
|
||||
return users.find(u => u.email === email) || null;
|
||||
}
|
||||
}
|
||||
|
||||
export { UserService, type User, type CreateUserDTO };`,
|
||||
language: "typescript",
|
||||
description: "Service implementation with domain events",
|
||||
tags: ["typescript", "service-layer", "domain-events"],
|
||||
postSlug: "architecture-patterns",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
groupId: "docker-deployment",
|
||||
title: "Docker Deployment Configuration",
|
||||
description: "Production-ready Docker setup",
|
||||
files: [
|
||||
{
|
||||
id: "dockerfile",
|
||||
filename: "Dockerfile",
|
||||
content: `# Multi-stage build for optimized production image
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production --ignore-scripts
|
||||
|
||||
# Build stage
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S astro -u 1001
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder --chown=astro:nodejs /app/dist ./dist
|
||||
COPY --from=deps --chown=astro:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=astro:nodejs /app/package*.json ./
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
||||
CMD node -e "require('http').get('http://localhost:4321/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Run as non-root
|
||||
USER astro
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "dist/server/entry.mjs"]`,
|
||||
language: "dockerfile",
|
||||
description: "Multi-stage Docker build for production",
|
||||
tags: ["docker", "production", "multi-stage"],
|
||||
postSlug: "docker-deployment",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: "docker-compose",
|
||||
filename: "docker-compose.yml",
|
||||
content: `version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
target: production
|
||||
ports:
|
||||
- "8080:4321"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=4321
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:4321/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
# Optional: Add Redis for caching
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
# Optional: Add Caddy for reverse proxy
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:`,
|
||||
language: "yaml",
|
||||
description: "Multi-service Docker Compose setup",
|
||||
tags: ["docker", "compose", "orchestration"],
|
||||
postSlug: "docker-deployment",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Helper functions for managing file examples
|
||||
export class FileExampleManager {
|
||||
static async getFileExample(id: string): Promise<FileExample | undefined> {
|
||||
// First check in-memory store
|
||||
const stored = fileExamplesStore.get(id);
|
||||
if (stored) return stored;
|
||||
|
||||
// Search in sample data
|
||||
for (const group of sampleFileExamples) {
|
||||
const file = group.files.find(f => f.id === id);
|
||||
if (file) return file;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static async getFilesByTag(tag: string): Promise<FileExample[]> {
|
||||
const results: FileExample[] = [];
|
||||
|
||||
for (const group of sampleFileExamples) {
|
||||
for (const file of group.files) {
|
||||
if (file.tags?.includes(tag)) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
static async searchFiles(query: string): Promise<FileExample[]> {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const results: FileExample[] = [];
|
||||
|
||||
for (const group of sampleFileExamples) {
|
||||
for (const file of group.files) {
|
||||
const searchable = [
|
||||
file.filename,
|
||||
file.description,
|
||||
file.language,
|
||||
...(file.tags || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (searchable.includes(lowerQuery)) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
static async getAvailableTags(): Promise<string[]> {
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const group of sampleFileExamples) {
|
||||
for (const file of group.files) {
|
||||
file.tags?.forEach(tag => tags.add(tag));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
static async createFileExample(example: Omit<FileExample, 'id' | 'createdAt' | 'updatedAt'>): Promise<FileExample> {
|
||||
const id = `${example.filename.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}`;
|
||||
const newExample: FileExample = {
|
||||
...example,
|
||||
id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
fileExamplesStore.set(id, newExample);
|
||||
return newExample;
|
||||
}
|
||||
|
||||
static async updateFileExample(id: string, updates: Partial<FileExample>): Promise<FileExample | undefined> {
|
||||
const existing = await this.getFileExample(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const updated: FileExample = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
fileExamplesStore.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
static async deleteFileExample(id: string): Promise<boolean> {
|
||||
return fileExamplesStore.delete(id);
|
||||
}
|
||||
|
||||
static async getAllGroups(): Promise<FileExampleGroup[]> {
|
||||
return sampleFileExamples;
|
||||
}
|
||||
|
||||
static async getGroup(groupId: string): Promise<FileExampleGroup | undefined> {
|
||||
return sampleFileExamples.find(g => g.groupId === groupId);
|
||||
}
|
||||
|
||||
static async downloadFile(id: string): Promise<{ filename: string; content: string; mimeType: string } | null> {
|
||||
const file = await this.getFileExample(id);
|
||||
if (!file) return null;
|
||||
|
||||
const mimeType = this.getMimeType(file.language);
|
||||
return {
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
mimeType
|
||||
};
|
||||
}
|
||||
|
||||
static async downloadMultiple(ids: string[]): Promise<Array<{ filename: string; content: string }>> {
|
||||
const files = await Promise.all(ids.map(id => this.getFileExample(id)));
|
||||
return files
|
||||
.filter((f): f is FileExample => f !== undefined)
|
||||
.map(f => ({ filename: f.filename, content: f.content }));
|
||||
}
|
||||
|
||||
private static getMimeType(language: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'python': 'text/x-python',
|
||||
'typescript': 'text/x-typescript',
|
||||
'javascript': 'text/javascript',
|
||||
'dockerfile': 'text/x-dockerfile',
|
||||
'yaml': 'text/yaml',
|
||||
'json': 'application/json',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'sql': 'text/x-sql',
|
||||
'bash': 'text/x-shellscript',
|
||||
'text': 'text/plain'
|
||||
};
|
||||
return mimeTypes[language] || 'text/plain';
|
||||
}
|
||||
}
|
||||
@@ -182,4 +182,4 @@ const { title, description = "Technical problem solver's blog - practical insigh
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
286
src/pages/api/download-zip.ts
Normal file
286
src/pages/api/download-zip.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { FileExampleManager } from '../../data/fileExamples';
|
||||
|
||||
// Simple ZIP creation without external dependencies
|
||||
class SimpleZipCreator {
|
||||
private files: Array<{ filename: string; content: string }> = [];
|
||||
|
||||
addFile(filename: string, content: string) {
|
||||
this.files.push({ filename, content });
|
||||
}
|
||||
|
||||
// Create a basic ZIP file structure
|
||||
create(): number[] {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: number[][] = [];
|
||||
|
||||
let offset = 0;
|
||||
const centralDirectory: Array<{
|
||||
name: string;
|
||||
offset: number;
|
||||
size: number;
|
||||
compressedSize: number;
|
||||
}> = [];
|
||||
|
||||
// Process each file
|
||||
for (const file of this.files) {
|
||||
const contentBytes = Array.from(encoder.encode(file.content));
|
||||
const filenameBytes = Array.from(encoder.encode(file.filename));
|
||||
|
||||
// Local file header
|
||||
const localHeader: number[] = [];
|
||||
|
||||
// Local file header signature (little endian)
|
||||
localHeader.push(0x50, 0x4b, 0x03, 0x04);
|
||||
// Version needed to extract
|
||||
localHeader.push(20, 0);
|
||||
// General purpose bit flag
|
||||
localHeader.push(0, 0);
|
||||
// Compression method (0 = store)
|
||||
localHeader.push(0, 0);
|
||||
// Last modified time/date
|
||||
localHeader.push(0, 0, 0, 0);
|
||||
// CRC32 (0 for simplicity)
|
||||
localHeader.push(0, 0, 0, 0);
|
||||
// Compressed size
|
||||
localHeader.push(...intToLittleEndian(contentBytes.length, 4));
|
||||
// Uncompressed size
|
||||
localHeader.push(...intToLittleEndian(contentBytes.length, 4));
|
||||
// Filename length
|
||||
localHeader.push(...intToLittleEndian(filenameBytes.length, 2));
|
||||
// Extra field length
|
||||
localHeader.push(0, 0);
|
||||
|
||||
// Add filename
|
||||
localHeader.push(...filenameBytes);
|
||||
|
||||
chunks.push(localHeader);
|
||||
chunks.push(contentBytes);
|
||||
|
||||
// Store info for central directory
|
||||
centralDirectory.push({
|
||||
name: file.filename,
|
||||
offset: offset,
|
||||
size: contentBytes.length,
|
||||
compressedSize: contentBytes.length
|
||||
});
|
||||
|
||||
offset += localHeader.length + contentBytes.length;
|
||||
}
|
||||
|
||||
// Central directory
|
||||
const centralDirectoryChunks: number[][] = [];
|
||||
let centralDirectoryOffset = offset;
|
||||
|
||||
for (const entry of centralDirectory) {
|
||||
const filenameBytes = Array.from(encoder.encode(entry.name));
|
||||
const centralHeader: number[] = [];
|
||||
|
||||
// Central directory header signature
|
||||
centralHeader.push(0x50, 0x4b, 0x01, 0x02);
|
||||
// Version made by
|
||||
centralHeader.push(20, 0);
|
||||
// Version needed to extract
|
||||
centralHeader.push(20, 0);
|
||||
// General purpose bit flag
|
||||
centralHeader.push(0, 0);
|
||||
// Compression method
|
||||
centralHeader.push(0, 0);
|
||||
// Last modified time/date
|
||||
centralHeader.push(0, 0, 0, 0);
|
||||
// CRC32
|
||||
centralHeader.push(0, 0, 0, 0);
|
||||
// Compressed size
|
||||
centralHeader.push(...intToLittleEndian(entry.compressedSize, 4));
|
||||
// Uncompressed size
|
||||
centralHeader.push(...intToLittleEndian(entry.size, 4));
|
||||
// Filename length
|
||||
centralHeader.push(...intToLittleEndian(filenameBytes.length, 2));
|
||||
// Extra field length
|
||||
centralHeader.push(0, 0);
|
||||
// File comment length
|
||||
centralHeader.push(0, 0);
|
||||
// Disk number start
|
||||
centralHeader.push(0, 0);
|
||||
// Internal file attributes
|
||||
centralHeader.push(0, 0);
|
||||
// External file attributes
|
||||
centralHeader.push(0, 0, 0, 0);
|
||||
// Relative offset of local header
|
||||
centralHeader.push(...intToLittleEndian(entry.offset, 4));
|
||||
|
||||
// Add filename
|
||||
centralHeader.push(...filenameBytes);
|
||||
|
||||
centralDirectoryChunks.push(centralHeader);
|
||||
}
|
||||
|
||||
const centralDirectorySize = centralDirectoryChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
|
||||
// End of central directory
|
||||
const endOfCentralDirectory: number[] = [];
|
||||
|
||||
// End of central directory signature
|
||||
endOfCentralDirectory.push(0x50, 0x4b, 0x05, 0x06);
|
||||
// Number of this disk
|
||||
endOfCentralDirectory.push(0, 0);
|
||||
// Number of the disk with the start of the central directory
|
||||
endOfCentralDirectory.push(0, 0);
|
||||
// Total number of entries on this disk
|
||||
endOfCentralDirectory.push(...intToLittleEndian(centralDirectory.length, 2));
|
||||
// Total number of entries
|
||||
endOfCentralDirectory.push(...intToLittleEndian(centralDirectory.length, 2));
|
||||
// Size of the central directory
|
||||
endOfCentralDirectory.push(...intToLittleEndian(centralDirectorySize, 4));
|
||||
// Offset of start of central directory
|
||||
endOfCentralDirectory.push(...intToLittleEndian(centralDirectoryOffset, 4));
|
||||
// ZIP file comment length
|
||||
endOfCentralDirectory.push(0, 0);
|
||||
|
||||
// Combine all chunks
|
||||
const result: number[] = [];
|
||||
chunks.forEach(chunk => result.push(...chunk));
|
||||
centralDirectoryChunks.forEach(chunk => result.push(...chunk));
|
||||
result.push(...endOfCentralDirectory);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert integer to little endian bytes
|
||||
function intToLittleEndian(value: number, bytes: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
result.push((value >> (i * 8)) & 0xff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileIds } = body;
|
||||
|
||||
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'fileIds array is required and must not be empty' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get file contents
|
||||
const files = await Promise.all(
|
||||
fileIds.map(async (id) => {
|
||||
const file = await FileExampleManager.getFileExample(id);
|
||||
if (!file) {
|
||||
throw new Error(`File with id ${id} not found`);
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
// Create ZIP
|
||||
const zipCreator = new SimpleZipCreator();
|
||||
files.forEach(file => {
|
||||
zipCreator.addFile(file.filename, file.content);
|
||||
});
|
||||
|
||||
const zipData = zipCreator.create();
|
||||
const blob = new Blob([new Uint8Array(zipData)], { type: 'application/zip' });
|
||||
|
||||
// Return ZIP file
|
||||
return new Response(blob, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="code-examples-${Date.now()}.zip"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('ZIP download error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create zip file', details: errorMessage }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Also support GET for single file download
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const fileId = url.searchParams.get('id');
|
||||
|
||||
if (!fileId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'id parameter is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const file = await FileExampleManager.getFileExample(fileId);
|
||||
|
||||
if (!file) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'File not found' }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const content = encoder.encode(file.content);
|
||||
const blob = new Blob([content], { type: getMimeType(file.language) });
|
||||
|
||||
return new Response(blob, {
|
||||
headers: {
|
||||
'Content-Type': getMimeType(file.language),
|
||||
'Content-Disposition': `attachment; filename="${file.filename}"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('File download error:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to download file' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get MIME type
|
||||
function getMimeType(language: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'python': 'text/x-python',
|
||||
'typescript': 'text/x-typescript',
|
||||
'javascript': 'text/javascript',
|
||||
'dockerfile': 'text/x-dockerfile',
|
||||
'yaml': 'text/yaml',
|
||||
'json': 'application/json',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'sql': 'text/x-sql',
|
||||
'bash': 'text/x-shellscript',
|
||||
'text': 'text/plain'
|
||||
};
|
||||
return mimeTypes[language] || 'text/plain';
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 => ({
|
||||
@@ -28,12 +29,14 @@ 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)
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout title={post.title} description={post.description}>
|
||||
<!-- Reading progress bar with gradient -->
|
||||
<div id="reading-progress" class="fixed top-0 left-0 h-1 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 z-50 transition-all duration-100" style="width: 0%"></div>
|
||||
|
||||
<!-- 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">
|
||||
@@ -170,10 +173,98 @@ const clapKey = `claps_${post.slug}`;
|
||||
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
|
||||
@@ -189,17 +280,6 @@ const clapKey = `claps_${post.slug}`;
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Back to top floating button -->
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="fixed bottom-6 right-6 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full p-4 shadow-lg hover:from-blue-700 hover:to-purple-700 hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-300 opacity-0 pointer-events-none z-40"
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<svg class="w-6 h-6" 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>
|
||||
|
||||
<script>
|
||||
// Reading progress bar with smooth gradient
|
||||
function updateReadingProgress() {
|
||||
@@ -713,4 +793,4 @@ const clapKey = `claps_${post.slug}`;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
</BaseLayout>
|
||||
@@ -18,22 +18,21 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
<!-- Everything on ONE minimalist page -->
|
||||
|
||||
<!-- Clean Hero Section -->
|
||||
<section class="py-16 md:py-20">
|
||||
<section class="pt-10 pb-8 md:pt-12 md:pb-10">
|
||||
<div class="max-w-3xl mx-auto px-6">
|
||||
<!-- Personal intro -->
|
||||
<div class="text-center mb-10 animate-fade-in">
|
||||
<h1 class="text-4xl md:text-5xl font-serif font-light text-slate-900 tracking-tight mb-4">
|
||||
<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-lg md:text-xl text-slate-600 leading-relaxed font-serif italic">
|
||||
<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-4 text-sm text-slate-500 font-sans mt-4">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 12a4 4 0 110-8 4 4 0 010 8z"/></svg>
|
||||
<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>•</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>Digital problem solver</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +67,7 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
|
||||
<!-- All Posts -->
|
||||
<section>
|
||||
<div id="posts-container" class="grid-responsive">
|
||||
<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>
|
||||
@@ -210,20 +209,7 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// Hover micro-interaction
|
||||
link.addEventListener('mouseenter', () => {
|
||||
const card = link.querySelector('.post-card') as HTMLElement;
|
||||
if (card) {
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
}
|
||||
});
|
||||
|
||||
link.addEventListener('mouseleave', () => {
|
||||
const card = link.querySelector('.post-card') as HTMLElement;
|
||||
if (card) {
|
||||
card.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
// Hover handled by CSS
|
||||
});
|
||||
|
||||
// Handle incoming transition (when coming back from post)
|
||||
@@ -264,7 +250,7 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style is:global>
|
||||
/* Post link wrapper for smooth transitions */
|
||||
.post-link {
|
||||
display: block;
|
||||
@@ -273,16 +259,18 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
}
|
||||
|
||||
.post-link .post-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
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(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
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.98);
|
||||
transform: translateY(0) scale(0.99);
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
/* Transition overlay */
|
||||
@@ -295,8 +283,12 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
/* Smooth animations for all elements */
|
||||
* {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -352,7 +344,17 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
|
||||
.tag-cloud a:hover {
|
||||
transform: translateY(-1px) rotate(-1deg);
|
||||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
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 */
|
||||
@@ -395,4 +397,4 @@ const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
</BaseLayout>
|
||||
@@ -2,7 +2,7 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Medium-inspired clean reading experience */
|
||||
/* Base styles - Tailwind only */
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@@ -20,35 +20,35 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl md:text-4xl leading-tight mb-8;
|
||||
@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-6 mt-12;
|
||||
@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-4 mt-8;
|
||||
@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-3 mt-6;
|
||||
@apply text-lg md:text-xl leading-tight mb-2 mt-4;
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-6 text-base leading-relaxed text-slate-700;
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
.lead {
|
||||
@apply text-xl md:text-2xl text-slate-600 mb-10 leading-relaxed;
|
||||
@apply text-xl md:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -57,168 +57,94 @@
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
@apply ml-6 mb-6;
|
||||
@apply ml-5 mb-4;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply mb-2;
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply bg-slate-100 px-1.5 py-0.5 rounded font-mono text-sm text-slate-700;
|
||||
@apply bg-slate-100 px-1 py-0.5 rounded font-mono text-sm text-slate-700;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 border-slate-300 pl-6 italic text-slate-600 my-6;
|
||||
@apply border-l-2 border-slate-300 pl-4 italic text-slate-600 my-4;
|
||||
}
|
||||
|
||||
/* Code formatting */
|
||||
code {
|
||||
@apply bg-slate-100 px-1.5 py-0.5 rounded font-mono text-sm text-slate-700;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code {
|
||||
@apply text-pink-600 bg-pink-50 border border-pink-200;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
@apply bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto my-6 border border-slate-700;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre code {
|
||||
@apply bg-transparent text-slate-100 px-0 py-0 border-0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Syntax highlighting colors */
|
||||
.token.comment { @apply text-slate-500 italic; }
|
||||
.token.keyword { @apply text-purple-400 font-semibold; }
|
||||
.token.string { @apply text-green-400; }
|
||||
.token.number { @apply text-orange-400; }
|
||||
.token.function { @apply text-blue-400; }
|
||||
.token.operator { @apply text-slate-300; }
|
||||
.token.punctuation { @apply text-slate-400; }
|
||||
.token.class-name { @apply text-yellow-400 font-semibold; }
|
||||
|
||||
/* Line numbers wrapper */
|
||||
.line-numbers {
|
||||
counter-reset: line;
|
||||
}
|
||||
|
||||
.line-numbers .line {
|
||||
counter-increment: line;
|
||||
position: relative;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.line-numbers .line::before {
|
||||
content: counter(line);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 2rem;
|
||||
text-align: right;
|
||||
color: #64748b;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Focus styles - Firefox compatible */
|
||||
/* Focus states */
|
||||
a:focus,
|
||||
button:focus,
|
||||
[tabindex]:focus,
|
||||
.post-link:focus,
|
||||
.highlighter-tag:focus,
|
||||
.clap-button-top:focus,
|
||||
.share-button-top:focus,
|
||||
#back-btn-top:focus,
|
||||
#back-btn-bottom:focus,
|
||||
#back-to-top: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium-inspired components */
|
||||
/* Components - Tailwind utility classes */
|
||||
@layer components {
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Header - removed for single page design */
|
||||
|
||||
/* Blog post card - refined with better spacing */
|
||||
.post-card {
|
||||
@apply mb-12 last:mb-0;
|
||||
/* Legacy hooks required by tests */
|
||||
.file-example {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.post-card h3 {
|
||||
@apply text-xl font-semibold mb-3 hover:text-blue-600 transition-colors cursor-pointer;
|
||||
font-weight: 600;
|
||||
.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-sm text-slate-500 font-sans mb-4;
|
||||
@apply text-xs text-slate-500 font-sans mb-2;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
@apply text-slate-700 mb-5 leading-relaxed;
|
||||
@apply text-slate-700 mb-2 leading-relaxed;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
@apply flex flex-wrap gap-2;
|
||||
@apply flex flex-wrap gap-1;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@apply text-xs font-sans bg-slate-100 text-slate-700 px-2 py-1 rounded hover:bg-slate-200 transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
@apply text-sm font-semibold text-blue-600 hover:text-blue-800 font-sans inline-flex items-center;
|
||||
}
|
||||
|
||||
|
||||
/* Article page */
|
||||
.article-header {
|
||||
@apply mb-10;
|
||||
@apply mb-8;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
@apply text-4xl md:text-5xl font-bold mb-4;
|
||||
@apply text-4xl md:text-5xl font-bold mb-3;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
@apply text-sm text-slate-500 font-sans mb-6;
|
||||
@apply text-sm text-slate-500 font-sans mb-5;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
@@ -226,250 +152,63 @@
|
||||
}
|
||||
|
||||
.article-content p {
|
||||
@apply mb-7;
|
||||
@apply mb-5;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
@apply text-2xl font-bold mt-10 mb-4;
|
||||
@apply text-2xl font-bold mt-8 mb-3;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
@apply text-xl font-bold mt-8 mb-3;
|
||||
@apply text-xl font-bold mt-6 mb-2;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
@apply ml-6 mb-7;
|
||||
@apply ml-6 mb-5;
|
||||
}
|
||||
|
||||
.article-content li {
|
||||
@apply mb-2;
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
@apply border-l-4 border-slate-400 pl-6 italic text-slate-600 my-8 text-xl;
|
||||
@apply border-l-2 border-slate-400 pl-4 italic text-slate-600 my-5 text-lg;
|
||||
}
|
||||
|
||||
/* Enhanced code blocks for articles */
|
||||
.article-content pre {
|
||||
@apply bg-slate-900 text-slate-100 p-5 rounded-lg overflow-x-auto my-6 border border-slate-700;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article-content pre code {
|
||||
@apply bg-transparent px-0 py-0;
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Line numbers for code blocks */
|
||||
.article-content pre.line-numbers {
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
|
||||
.article-content pre.line-numbers::before {
|
||||
content: attr(data-line-numbers);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2.5rem;
|
||||
background: #1e293b;
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
padding: 1.25rem 0.5rem 1.25rem 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.6;
|
||||
border-right: 1px solid #334155;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Inline code in articles */
|
||||
.article-content p code,
|
||||
.article-content li code,
|
||||
.article-content blockquote code {
|
||||
@apply bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded font-mono text-sm border border-pink-200;
|
||||
}
|
||||
|
||||
/* Syntax highlighting classes */
|
||||
.article-content .token.comment { @apply text-slate-500 italic; }
|
||||
.article-content .token.keyword { @apply text-purple-400 font-semibold; }
|
||||
.article-content .token.string { @apply text-green-400; }
|
||||
.article-content .token.number { @apply text-orange-400; }
|
||||
.article-content .token.function { @apply text-blue-400; }
|
||||
.article-content .token.operator { @apply text-slate-300; }
|
||||
.article-content .token.punctuation { @apply text-slate-400; }
|
||||
.article-content .token.class-name { @apply text-yellow-400 font-semibold; }
|
||||
.article-content .token.tag { @apply text-red-400; }
|
||||
.article-content .token.attr-name { @apply text-purple-400; }
|
||||
.article-content .token.attr-value { @apply text-green-400; }
|
||||
|
||||
/* Code language badge */
|
||||
.article-content pre::after {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* About page sections */
|
||||
.about-section {
|
||||
@apply mb-10 pb-8 border-b border-slate-200 last:border-0;
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
@apply text-2xl font-bold mb-4;
|
||||
}
|
||||
|
||||
.about-list {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.about-list li {
|
||||
@apply flex items-start;
|
||||
}
|
||||
|
||||
.about-list li::before {
|
||||
content: "→";
|
||||
@apply mr-2 text-blue-600 font-bold;
|
||||
}
|
||||
|
||||
/* Footer - removed for single page design */
|
||||
|
||||
/* Simple button */
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply inline-block px-5 py-2 bg-slate-900 text-white font-sans font-medium hover:bg-slate-700 transition-colors no-underline rounded;
|
||||
@apply inline-block px-4 py-2 bg-slate-900 text-white font-sans font-medium hover:bg-slate-700 transition-colors rounded;
|
||||
}
|
||||
|
||||
/* Highlighter-style tags - lovely note-taking style */
|
||||
.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);
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition-colors;
|
||||
}
|
||||
|
||||
.highlighter-yellow {
|
||||
@apply bg-yellow-300 text-yellow-900;
|
||||
background: linear-gradient(180deg, rgba(255,235,59,0.9) 0%, rgba(255,213,79,0.9) 100%);
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 hover:bg-slate-100 border border-slate-300 px-3 py-1.5 rounded transition-colors;
|
||||
}
|
||||
|
||||
.highlighter-yellow:hover {
|
||||
transform: rotate(-2deg) scale(1.1);
|
||||
box-shadow: 3px 3px 0 rgba(0,0,0,0.15);
|
||||
|
||||
/* Hide scrollbars */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.highlighter-pink {
|
||||
@apply bg-pink-300 text-pink-900;
|
||||
background: linear-gradient(180deg, rgba(255,167,209,0.9) 0%, rgba(255,122,175,0.9) 100%);
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.highlighter-pink:hover {
|
||||
transform: rotate(-2deg) scale(1.1);
|
||||
box-shadow: 3px 3px 0 rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.highlighter-green {
|
||||
@apply bg-green-300 text-green-900;
|
||||
background: linear-gradient(180deg, rgba(129,199,132,0.9) 0%, rgba(102,187,106,0.9) 100%);
|
||||
}
|
||||
|
||||
.highlighter-green:hover {
|
||||
transform: rotate(-2deg) scale(1.1);
|
||||
box-shadow: 3px 3px 0 rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.highlighter-blue {
|
||||
@apply bg-blue-300 text-blue-900;
|
||||
background: linear-gradient(180deg, rgba(100,181,246,0.9) 0%, rgba(66,165,245,0.9) 100%);
|
||||
}
|
||||
|
||||
.highlighter-blue:hover {
|
||||
transform: rotate(-2deg) scale(1.1);
|
||||
box-shadow: 3px 3px 0 rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Scribble underline for emphasis */
|
||||
.scribble-emphasis {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scribble-emphasis::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
height: 7px;
|
||||
background: url("data:image/svg+xml,%3Csvg width='40' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 4 Q10 2, 20 4 T38 4' stroke='%23FFEB3B' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") repeat-x;
|
||||
background-size: 40px 8px;
|
||||
opacity: 0.7;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Sticky note effect */
|
||||
.sticky-note {
|
||||
@apply p-4 rounded shadow-sm border;
|
||||
background: linear-gradient(135deg, #fff9c4 0%, #fff59d 100%);
|
||||
border-color: #f9a825;
|
||||
transform: rotate(-0.5deg);
|
||||
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sticky-note:hover {
|
||||
transform: rotate(0deg) scale(1.02);
|
||||
box-shadow: 3px 3px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Handwritten style */
|
||||
.handwritten {
|
||||
font-family: 'Comic Sans MS', 'Chalkboard SE', 'Comic Neue', cursive;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* Search box styling */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Tag cloud */
|
||||
.tag-cloud {
|
||||
@apply flex flex-wrap gap-2 items-center;
|
||||
}
|
||||
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply text-center py-12 text-slate-500;
|
||||
@apply text-center py-8 text-slate-500;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
@apply mx-auto mb-4 text-slate-300;
|
||||
@apply mx-auto mb-2 text-slate-300;
|
||||
}
|
||||
|
||||
/* Line clamp utility for text truncation */
|
||||
/* Line clamp utility */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -477,103 +216,71 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle gradient backgrounds */
|
||||
.subtle-gradient {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(248,250,252,0.1) 100%);
|
||||
}
|
||||
|
||||
/* Enhanced focus states */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white;
|
||||
}
|
||||
|
||||
/* Reading progress indicator */
|
||||
.reading-progress {
|
||||
.reading-progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
transform-origin: left;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Smooth animations */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Enhanced typography for better readability */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Subtle shadow variations */
|
||||
.shadow-soft {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.shadow-hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* Improved button styles */
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white font-sans font-medium px-6 py-3 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 font-sans font-medium px-6 py-3 rounded-lg border border-slate-200 hover:bg-slate-50 focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-published {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
/* Responsive grid improvements */
|
||||
.grid-responsive {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Better spacing utilities */
|
||||
.space-y-fluid > * + * {
|
||||
margin-top: clamp(1rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
/* Enhanced link styles */
|
||||
.link-enhanced {
|
||||
@apply text-blue-600 hover:text-blue-800 transition-colors duration-200 relative;
|
||||
}
|
||||
|
||||
.link-enhanced::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
transition: width 0.3s ease;
|
||||
background: #3b82f6;
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
z-index: 50;
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.link-enhanced:hover::after {
|
||||
width: 100%;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
261
src/utils/test-component-integration.ts
Normal file
261
src/utils/test-component-integration.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Test the integration between blog posts and file examples
|
||||
* This simulates what happens when a blog post is rendered
|
||||
*/
|
||||
|
||||
import { blogPosts } from '../data/blogPosts';
|
||||
import { FileExampleManager } from '../data/fileExamples';
|
||||
|
||||
export async function testBlogPostIntegration() {
|
||||
console.log('🧪 Testing Blog Post + File Examples Integration...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const test = (name: string, fn: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = fn();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(() => {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
});
|
||||
} else {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1: Blog posts exist
|
||||
test('Blog posts are loaded', () => {
|
||||
if (!blogPosts || blogPosts.length === 0) {
|
||||
throw new Error('No blog posts found');
|
||||
}
|
||||
console.log(` Found ${blogPosts.length} posts`);
|
||||
});
|
||||
|
||||
// Test 2: Each post has required fields
|
||||
test('All posts have required fields', () => {
|
||||
for (const post of blogPosts) {
|
||||
if (!post.slug || !post.title || !post.tags) {
|
||||
throw new Error(`Post ${post.slug} missing required fields`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Debugging-tips post should have file examples
|
||||
test('debugging-tips post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'debugging-tips');
|
||||
if (!post) {
|
||||
throw new Error('debugging-tips post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
// debugging-tips has tags ['debugging', 'tools'] so showFileExamples would be false
|
||||
// But it has hardcoded FileExamplesList in the template
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for debugging-tips');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for debugging-tips`);
|
||||
});
|
||||
|
||||
// Test 4: Architecture-patterns post should have file examples
|
||||
test('architecture-patterns post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'architecture-patterns');
|
||||
if (!post) {
|
||||
throw new Error('architecture-patterns post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (!showFileExamples) {
|
||||
throw new Error('architecture-patterns should show file examples');
|
||||
}
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'architecture-patterns');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for architecture-patterns');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for architecture-patterns`);
|
||||
});
|
||||
|
||||
// Test 5: Docker-deployment post should have file examples
|
||||
test('docker-deployment post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'docker-deployment');
|
||||
if (!post) {
|
||||
throw new Error('docker-deployment post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (!showFileExamples) {
|
||||
throw new Error('docker-deployment should show file examples');
|
||||
}
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'docker-deployment');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for docker-deployment');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for docker-deployment`);
|
||||
});
|
||||
|
||||
// Test 6: First-note post should NOT have file examples
|
||||
test('first-note post has no file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'first-note');
|
||||
if (!post) {
|
||||
throw new Error('first-note post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (showFileExamples) {
|
||||
throw new Error('first-note should NOT show file examples');
|
||||
}
|
||||
|
||||
// Verify no files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'first-note');
|
||||
|
||||
if (filesForPost.length > 0) {
|
||||
throw new Error('Files found for first-note, but none should exist');
|
||||
}
|
||||
|
||||
console.log(` Correctly has no files`);
|
||||
});
|
||||
|
||||
// Test 7: Simulate FileExamplesList filtering for debugging-tips
|
||||
test('FileExamplesList filtering works for debugging-tips', async () => {
|
||||
const postSlug = 'debugging-tips';
|
||||
const groupId = 'python-data-processing';
|
||||
|
||||
const allGroups = await FileExampleManager.getAllGroups();
|
||||
const loadedGroups = allGroups
|
||||
.filter(g => g.groupId === groupId)
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => f.postSlug === postSlug)
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (loadedGroups.length === 0) {
|
||||
throw new Error('No groups loaded for debugging-tips with python-data-processing');
|
||||
}
|
||||
|
||||
if (loadedGroups[0].files.length === 0) {
|
||||
throw new Error('No files in the group');
|
||||
}
|
||||
|
||||
console.log(` Would show ${loadedGroups[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 8: Simulate FileExamplesList filtering for architecture-patterns
|
||||
test('FileExamplesList filtering works for architecture-patterns', async () => {
|
||||
const postSlug = 'architecture-patterns';
|
||||
const tags = ['architecture', 'design-patterns', 'system-design'];
|
||||
|
||||
const allGroups = await FileExampleManager.getAllGroups();
|
||||
const loadedGroups = allGroups
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => {
|
||||
if (f.postSlug !== postSlug) return false;
|
||||
if (tags && tags.length > 0) {
|
||||
return f.tags?.some(tag => tags.includes(tag));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (loadedGroups.length === 0) {
|
||||
throw new Error('No groups loaded for architecture-patterns');
|
||||
}
|
||||
|
||||
const totalFiles = loadedGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||
if (totalFiles === 0) {
|
||||
throw new Error('No files found');
|
||||
}
|
||||
|
||||
console.log(` Would show ${totalFiles} files across ${loadedGroups.length} groups`);
|
||||
});
|
||||
|
||||
// Test 9: Verify all file examples have postSlug
|
||||
test('All file examples have postSlug property', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesWithoutPostSlug = groups.flatMap(g => g.files).filter(f => !f.postSlug);
|
||||
|
||||
if (filesWithoutPostSlug.length > 0) {
|
||||
throw new Error(`${filesWithoutPostSlug.length} files missing postSlug`);
|
||||
}
|
||||
|
||||
console.log(` All ${groups.flatMap(g => g.files).length} files have postSlug`);
|
||||
});
|
||||
|
||||
// Test 10: Verify postSlugs match blog post slugs
|
||||
test('File example postSlugs match blog post slugs', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filePostSlugs = new Set(groups.flatMap(g => g.files).map(f => f.postSlug));
|
||||
const blogPostSlugs = new Set(blogPosts.map(p => p.slug));
|
||||
|
||||
for (const slug of filePostSlugs) {
|
||||
if (slug && !blogPostSlugs.has(slug)) {
|
||||
throw new Error(`File postSlug "${slug}" doesn't match any blog post`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` All file postSlugs match blog posts`);
|
||||
});
|
||||
|
||||
// Wait for async tests
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`\n📊 Integration Test Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('🎉 All integration tests passed!');
|
||||
console.log('\n✅ The file examples system is correctly integrated with blog posts!');
|
||||
} else {
|
||||
console.log('❌ Some integration tests failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other test files
|
||||
264
src/utils/test-file-examples.ts
Normal file
264
src/utils/test-file-examples.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Comprehensive tests for the file examples system
|
||||
*/
|
||||
|
||||
import { FileExampleManager, sampleFileExamples, type FileExample } from '../data/fileExamples';
|
||||
|
||||
// Test helper to run all tests
|
||||
export async function runFileExamplesTests() {
|
||||
console.log('🧪 Running File Examples System Tests...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const test = (name: string, fn: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = fn();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(() => {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
});
|
||||
} else {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1: Data structure exists
|
||||
test('File examples data is loaded', () => {
|
||||
if (!sampleFileExamples || sampleFileExamples.length === 0) {
|
||||
throw new Error('No file examples found');
|
||||
}
|
||||
console.log(` Found ${sampleFileExamples.length} groups`);
|
||||
});
|
||||
|
||||
// Test 2: FileExampleManager exists
|
||||
test('FileExampleManager class is available', () => {
|
||||
if (!FileExampleManager) {
|
||||
throw new Error('FileExampleManager not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Sample data has correct structure
|
||||
test('Sample data has correct structure', () => {
|
||||
const group = sampleFileExamples[0];
|
||||
if (!group.groupId || !group.title || !Array.isArray(group.files)) {
|
||||
throw new Error('Invalid group structure');
|
||||
}
|
||||
|
||||
const file = group.files[0];
|
||||
if (!file.id || !file.filename || !file.content || !file.language) {
|
||||
throw new Error('Invalid file structure');
|
||||
}
|
||||
|
||||
// Check for postSlug
|
||||
if (!file.postSlug) {
|
||||
throw new Error('Files missing postSlug property');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Get all groups
|
||||
test('FileExampleManager.getAllGroups() works', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
if (!Array.isArray(groups) || groups.length === 0) {
|
||||
throw new Error('getAllGroups returned invalid result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Get specific group
|
||||
test('FileExampleManager.getGroup() works', async () => {
|
||||
const group = await FileExampleManager.getGroup('python-data-processing');
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
if (group.groupId !== 'python-data-processing') {
|
||||
throw new Error('Wrong group returned');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Search files
|
||||
test('FileExampleManager.searchFiles() works', async () => {
|
||||
const results = await FileExampleManager.searchFiles('python');
|
||||
if (!Array.isArray(results)) {
|
||||
throw new Error('searchFiles returned invalid result');
|
||||
}
|
||||
if (results.length === 0) {
|
||||
throw new Error('No results found for "python"');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: Get file by ID
|
||||
test('FileExampleManager.getFileExample() works', async () => {
|
||||
const file = await FileExampleManager.getFileExample('python-data-processor');
|
||||
if (!file) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
if (file.id !== 'python-data-processor') {
|
||||
throw new Error('Wrong file returned');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8: Filter by postSlug
|
||||
test('Filter files by postSlug', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const debuggingFiles = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
|
||||
|
||||
if (debuggingFiles.length === 0) {
|
||||
throw new Error('No files found for debugging-tips');
|
||||
}
|
||||
|
||||
console.log(` Found ${debuggingFiles.length} files for debugging-tips`);
|
||||
});
|
||||
|
||||
// Test 9: Filter by postSlug and groupId
|
||||
test('Filter files by postSlug and groupId', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filtered = groups
|
||||
.filter(g => g.groupId === 'python-data-processing')
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => f.postSlug === 'debugging-tips')
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('No files found for debugging-tips in python-data-processing');
|
||||
}
|
||||
|
||||
console.log(` Found ${filtered[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 10: Filter by postSlug and tags
|
||||
test('Filter files by postSlug and tags', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const tags = ['architecture', 'design-patterns'];
|
||||
|
||||
const filtered = groups
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f =>
|
||||
f.postSlug === 'architecture-patterns' &&
|
||||
f.tags?.some(tag => tags.includes(tag))
|
||||
)
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('No files found for architecture-patterns with tags');
|
||||
}
|
||||
|
||||
console.log(` Found ${filtered[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 11: Download single file
|
||||
test('Download single file', async () => {
|
||||
const result = await FileExampleManager.downloadFile('python-data-processor');
|
||||
if (!result) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
if (!result.filename || !result.content || !result.mimeType) {
|
||||
throw new Error('Invalid download result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 12: Download multiple files
|
||||
test('Download multiple files', async () => {
|
||||
const files = await FileExampleManager.downloadMultiple(['python-data-processor', 'python-config-example']);
|
||||
if (!Array.isArray(files) || files.length !== 2) {
|
||||
throw new Error('Invalid multiple download result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 13: Get available tags
|
||||
test('Get available tags', async () => {
|
||||
const tags = await FileExampleManager.getAvailableTags();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
throw new Error('No tags found');
|
||||
}
|
||||
if (!tags.includes('python') || !tags.includes('architecture')) {
|
||||
throw new Error('Expected tags not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 14: Create new file example
|
||||
test('Create new file example', async () => {
|
||||
const newExample = await FileExampleManager.createFileExample({
|
||||
filename: 'test.py',
|
||||
content: 'print("test")',
|
||||
language: 'python',
|
||||
description: 'Test file',
|
||||
tags: ['test'],
|
||||
postSlug: 'test-post'
|
||||
});
|
||||
|
||||
if (!newExample.id) {
|
||||
throw new Error('New example has no ID');
|
||||
}
|
||||
|
||||
// Verify it was added
|
||||
const retrieved = await FileExampleManager.getFileExample(newExample.id);
|
||||
if (!retrieved || retrieved.filename !== 'test.py') {
|
||||
throw new Error('New example not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 15: Update file example
|
||||
test('Update file example', async () => {
|
||||
const updated = await FileExampleManager.updateFileExample('python-data-processor', {
|
||||
description: 'Updated description'
|
||||
});
|
||||
|
||||
if (!updated || updated.description !== 'Updated description') {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 16: Delete file example
|
||||
test('Delete file example', async () => {
|
||||
// First create one
|
||||
const created = await FileExampleManager.createFileExample({
|
||||
filename: 'delete-test.py',
|
||||
content: 'test',
|
||||
language: 'python',
|
||||
postSlug: 'test'
|
||||
});
|
||||
|
||||
// Then delete it
|
||||
const deleted = await FileExampleManager.deleteFileExample(created.id);
|
||||
if (!deleted) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
|
||||
// Verify it's gone
|
||||
const retrieved = await FileExampleManager.getFileExample(created.id);
|
||||
if (retrieved) {
|
||||
throw new Error('File still exists after deletion');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all async tests to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('🎉 All tests passed!');
|
||||
} else {
|
||||
console.log('❌ Some tests failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other test files
|
||||
Reference in New Issue
Block a user