This commit is contained in:
2026-01-13 00:00:22 +01:00
parent 38d0e7e0a0
commit 19081ec682
23 changed files with 5023 additions and 521 deletions

View File

@@ -1,15 +1,15 @@
# Marc — technical problem solver
# Marc — digital problem solver
## Identity
- Name: Marc Mintel
- Mail: marc@mintel.me
- Location: Vulkaneifel, Germany
- Role: Independent technical problem solver
- Role: Independent digital problem solver
- Mode: Solo
- Focus: Understanding problems and building practical solutions
## What I do
I work on technical problems and build tools, scripts, and systems to solve them.
I work on digital problems and build tools, scripts, and systems to solve them.
Sometimes that means code, sometimes automation, sometimes AI, sometimes something else.
The tool is secondary. The problem comes first.

1797
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,29 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"test": "npm run test:smoke",
"test:smoke": "tsx ./scripts/smoke-test.ts",
"test:links": "tsx ./scripts/test-links.ts"
},
"dependencies": {
"@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2",
"@astrojs/tailwind": "^6.0.2",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"astro": "^5.16.8",
"lucide-react": "^0.468.0",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"react-dom": "^19.2.3",
"shiki": "^1.24.2",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^25.0.6",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tsx": "^4.21.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

576
scripts/smoke-test.ts Normal file
View File

@@ -0,0 +1,576 @@
#!/usr/bin/env tsx
/**
* Simple smoke test for the blog
* Tests: Build succeeds, key files exist, data is valid
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
console.log('🔍 Running smoke tests for mintel.me blog...\n');
let passed = 0;
let failed = 0;
function test(name: string, fn: () => void): void {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
if (error instanceof Error) {
console.log(` Error: ${error.message}`);
}
failed++;
}
}
// Test 1: Check required files exist
test('Required files exist', () => {
const requiredFiles = [
'package.json',
'astro.config.mjs',
'tailwind.config.js',
'src/pages/index.astro',
'src/layouts/BaseLayout.astro',
'src/data/blogPosts.ts',
'src/styles/global.css'
];
for (const file of requiredFiles) {
if (!fs.existsSync(path.join(process.cwd(), file))) {
throw new Error(`Missing file: ${file}`);
}
}
});
// Test 2: Validate blog posts data
test('Blog posts data is valid', () => {
const blogPostsPath = path.join(process.cwd(), 'src/data/blogPosts.ts');
const content = fs.readFileSync(blogPostsPath, 'utf8');
// Check for basic structure
if (!content.includes('export const blogPosts')) {
throw new Error('blogPosts export not found');
}
// Check for required fields in posts
const postsMatch = content.match(/\{[^}]+\}/g);
if (!postsMatch || postsMatch.length === 0) {
throw new Error('No posts found in blogPosts.ts');
}
// Validate at least one post has required fields
const firstPost = postsMatch[0];
const requiredFields = ['title', 'description', 'date', 'slug', 'tags'];
for (const field of requiredFields) {
if (!firstPost.includes(field)) {
throw new Error(`First post missing required field: ${field}`);
}
}
});
// Test 3: Check Astro config
test('Astro configuration is valid', () => {
const configPath = path.join(process.cwd(), 'astro.config.mjs');
const content = fs.readFileSync(configPath, 'utf8');
if (!content.includes('site:')) {
throw new Error('site URL not configured');
}
if (!content.includes('react()')) {
throw new Error('React integration not configured');
}
});
// Test 4: Validate Tailwind config
test('Tailwind configuration is valid', () => {
const configPath = path.join(process.cwd(), 'tailwind.config.js');
const content = fs.readFileSync(configPath, 'utf8');
if (!content.includes('content:')) {
throw new Error('content paths not configured');
}
if (!content.includes('plugins:')) {
throw new Error('plugins not configured');
}
});
// Test 5: Check for syntax errors in key components
test('Key components have valid syntax', () => {
const components = [
'src/components/MediumCard.tsx',
'src/components/SearchBar.tsx',
'src/components/ArticleBlockquote.tsx'
];
for (const component of components) {
const componentPath = path.join(process.cwd(), component);
if (fs.existsSync(componentPath)) {
const content = fs.readFileSync(componentPath, 'utf8');
// Basic syntax checks
if (content.includes('import') && !content.includes('export')) {
throw new Error(`${component}: has imports but no exports`);
}
// Check for balanced braces
const openBraces = (content.match(/{/g) || []).length;
const closeBraces = (content.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
throw new Error(`${component}: unbalanced braces`);
}
}
}
});
// Test 6: Check global styles
test('Global styles include required classes', () => {
const stylesPath = path.join(process.cwd(), 'src/styles/global.css');
const content = fs.readFileSync(stylesPath, 'utf8');
const requiredClasses = [
'.container',
'.highlighter-tag',
'.post-card',
'.article-content',
'.search-box'
];
for (const className of requiredClasses) {
if (!content.includes(className)) {
throw new Error(`Missing required class: ${className}`);
}
}
});
// Test 7: Verify package.json scripts
test('Package.json has required scripts', () => {
const packagePath = path.join(process.cwd(), 'package.json');
const content = fs.readFileSync(packagePath, 'utf8');
const requiredScripts = ['dev', 'build', 'preview', 'test:smoke'];
for (const script of requiredScripts) {
if (!content.includes(`"${script}"`)) {
throw new Error(`Missing script: ${script}`);
}
}
});
// Test 8: Check for single-page blog functionality
test('Single-page blog functionality exists', () => {
const functionalityFiles = [
'src/pages/index.astro', // Single page with everything
'src/components/SearchBar.tsx', // Search component
'src/components/MediumCard.tsx' // Post cards
];
for (const file of functionalityFiles) {
const filePath = path.join(process.cwd(), file);
if (!fs.existsSync(filePath)) {
throw new Error(`Missing functionality file: ${file}`);
}
}
});
// Test 9: Check that all blog posts load in the home page
test('All blog posts load in home page', () => {
const homePagePath = path.join(process.cwd(), 'src/pages/index.astro');
const homeContent = fs.readFileSync(homePagePath, 'utf8');
// Check that it imports blogPosts
if (!homeContent.includes('import') || !homeContent.includes('blogPosts')) {
throw new Error('Home page does not import blogPosts');
}
// Check that it renders posts
if (!homeContent.includes('allPosts.map') && !homeContent.includes('blogPosts.map')) {
throw new Error('Home page does not render blog posts');
}
// Check that MediumCard is imported and used
if (!homeContent.includes('MediumCard')) {
throw new Error('MediumCard component not used in home page');
}
});
// Test 10: Verify blog posts have all required fields
test('Blog posts have all required fields', () => {
const blogPostsPath = path.join(process.cwd(), 'src/data/blogPosts.ts');
const content = fs.readFileSync(blogPostsPath, 'utf8');
// Extract all posts
const posts = content.match(/\{[^}]+\}/g) || [];
if (posts.length === 0) {
throw new Error('No posts found in blogPosts.ts');
}
// Check each post has required fields
const requiredFields = ['title', 'description', 'date', 'slug', 'tags'];
posts.forEach((post, index) => {
for (const field of requiredFields) {
if (!post.includes(field)) {
throw new Error(`Post ${index + 1} missing required field: ${field}`);
}
}
});
});
// Test 11: Verify individual blog post pages exist
test('Individual blog post pages exist', () => {
const blogDir = path.join(process.cwd(), 'src/pages/blog');
const files = fs.readdirSync(blogDir);
const astroFiles = files.filter(f => f.endsWith('.astro'));
if (astroFiles.length === 0) {
throw new Error('No blog post files found');
}
// Check that [slug].astro exists for dynamic routing
if (!files.includes('[slug].astro')) {
throw new Error('Missing [slug].astro for dynamic blog post routing');
}
// Check that individual post files exist
const requiredPosts = ['first-note.astro', 'debugging-tips.astro'];
for (const post of requiredPosts) {
if (!astroFiles.includes(post)) {
throw new Error(`Missing blog post file: ${post}`);
}
}
});
// Test 12: Verify MediumCard links to individual posts
test('MediumCard links to individual post pages', () => {
const mediumCardPath = path.join(process.cwd(), 'src/components/MediumCard.tsx');
const content = fs.readFileSync(mediumCardPath, 'utf8');
// Should contain href to blog posts
if (!content.includes('href={`/blog/')) {
throw new Error('MediumCard should contain links to individual post pages');
}
// Should contain <a> tags
if (!content.includes('<a ') || !content.includes('</a>')) {
throw new Error('MediumCard should contain anchor tags');
}
// Should also have tag filtering
if (!content.includes('onClick')) {
throw new Error('MediumCard should have onClick handlers for tags');
}
});
// Test 13: Verify home page has no navigation
test('Home page has no navigation links', () => {
const homePagePath = path.join(process.cwd(), 'src/pages/index.astro');
const content = fs.readFileSync(homePagePath, 'utf8');
// Should not contain navigation links
if (content.includes('href="/about"') || content.includes('href="/blog"')) {
throw new Error('Home page contains navigation links');
}
// Should not contain nav or header navigation
if (content.includes('<nav') || content.includes('nav class')) {
throw new Error('Home page contains navigation elements');
}
});
// Test 14: Verify blog post pages don't import BlogLayout
test('Blog posts use BaseLayout not BlogLayout', () => {
const blogDir = path.join(process.cwd(), 'src/pages/blog');
const files = fs.readdirSync(blogDir);
const astroFiles = files.filter(f => f.endsWith('.astro') && f !== '[slug].astro');
for (const file of astroFiles) {
const content = fs.readFileSync(path.join(blogDir, file), 'utf8');
if (content.includes('import BlogLayout')) {
throw new Error(`${file} still imports BlogLayout which doesn't exist`);
}
if (!content.includes('import BaseLayout')) {
throw new Error(`${file} doesn't import BaseLayout`);
}
}
});
// Test 15: Verify blog post pages have proper structure
test('Blog posts have proper structure', () => {
const blogDir = path.join(process.cwd(), 'src/pages/blog');
const files = fs.readdirSync(blogDir);
const astroFiles = files.filter(f => f.endsWith('.astro') && f !== '[slug].astro');
for (const file of astroFiles) {
const content = fs.readFileSync(path.join(blogDir, file), 'utf8');
// Should close BaseLayout properly
if (!content.includes('</BaseLayout>')) {
throw new Error(`${file} doesn't close BaseLayout properly`);
}
// Should have article wrapper
if (!content.includes('<article')) {
throw new Error(`${file} missing article wrapper`);
}
// Should have header with title
if (!content.includes('<header') || !content.includes('</header>')) {
throw new Error(`${file} missing header section`);
}
}
});
// Test 16: Verify all imports in blog posts are valid
test('All imports in blog posts are valid', () => {
const blogDir = path.join(process.cwd(), 'src/pages/blog');
const files = fs.readdirSync(blogDir);
const astroFiles = files.filter(f => f.endsWith('.astro') && f !== '[slug].astro');
for (const file of astroFiles) {
const content = fs.readFileSync(path.join(blogDir, file), 'utf8');
// Extract all imports
const importMatches = content.match(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g) || [];
for (const importLine of importMatches) {
// Extract the path
const pathMatch = importLine.match(/from\s+['"]([^'"]+)['"]/);
if (!pathMatch) continue;
const importPath = pathMatch[1];
// Skip relative imports for now, just check they don't reference BlogLayout
if (importPath.includes('BlogLayout')) {
throw new Error(`${file} imports BlogLayout which doesn't exist`);
}
// Check that BaseLayout is imported correctly
if (importPath.includes('BaseLayout') && !importPath.includes('../../layouts/BaseLayout')) {
throw new Error(`${file} has incorrect BaseLayout import path`);
}
}
}
});
// Test 17: Verify tag pages exist and are valid
test('Tag pages exist and are valid', () => {
const tagPagePath = path.join(process.cwd(), 'src/pages/tags/[tag].astro');
if (!fs.existsSync(tagPagePath)) {
throw new Error('Tag page [tag].astro does not exist');
}
const content = fs.readFileSync(tagPagePath, 'utf8');
// Should import BaseLayout
if (!content.includes('import BaseLayout')) {
throw new Error('Tag page does not import BaseLayout');
}
// Should have getStaticPaths
if (!content.includes('getStaticPaths')) {
throw new Error('Tag page missing getStaticPaths function');
}
// Should render posts
if (!content.includes('posts.map')) {
throw new Error('Tag page does not render posts');
}
});
// Test 18: Verify [slug].astro page exists and is valid
test('Dynamic slug page exists and is valid', () => {
const slugPagePath = path.join(process.cwd(), 'src/pages/blog/[slug].astro');
if (!fs.existsSync(slugPagePath)) {
throw new Error('Dynamic slug page [slug].astro does not exist');
}
const content = fs.readFileSync(slugPagePath, 'utf8');
// Should import BaseLayout
if (!content.includes('import BaseLayout')) {
throw new Error('Slug page does not import BaseLayout');
}
// Should have getStaticPaths
if (!content.includes('getStaticPaths')) {
throw new Error('Slug page missing getStaticPaths function');
}
// Should render post content
if (!content.includes('post.title') || !content.includes('post.description')) {
throw new Error('Slug page does not render post data');
}
});
// Test 19: Verify no syntax errors in any Astro files
test('No syntax errors in Astro files', () => {
const astroFiles = [
'src/pages/index.astro',
'src/pages/blog/[slug].astro',
'src/pages/blog/first-note.astro',
'src/pages/blog/debugging-tips.astro',
'src/pages/tags/[tag].astro',
'src/layouts/BaseLayout.astro'
];
for (const file of astroFiles) {
const filePath = path.join(process.cwd(), file);
if (!fs.existsSync(filePath)) {
throw new Error(`File ${file} does not exist`);
}
const content = fs.readFileSync(filePath, 'utf8');
// Check for balanced HTML tags
const openTags = (content.match(/<[^/][^>]*>/g) || []).length;
const closeTags = (content.match(/<\/[^>]+>/g) || []).length;
// Check for balanced braces in script sections
const scriptSections = content.match(/---[\s\S]*?---/g) || [];
let totalOpenBraces = 0;
let totalCloseBraces = 0;
for (const section of scriptSections) {
totalOpenBraces += (section.match(/{/g) || []).length;
totalCloseBraces += (section.match(/}/g) || []).length;
}
if (totalOpenBraces !== totalCloseBraces) {
throw new Error(`${file} has unbalanced braces in script section`);
}
}
});
// Test 20: Verify blogPosts.ts can be imported without errors
test('blogPosts.ts can be imported without errors', () => {
const blogPostsPath = path.join(process.cwd(), 'src/data/blogPosts.ts');
const content = fs.readFileSync(blogPostsPath, 'utf8');
// Check for basic syntax
if (!content.includes('export const blogPosts')) {
throw new Error('blogPosts.ts does not export blogPosts');
}
// Check that it's valid TypeScript
if (content.includes('interface') || content.includes('type')) {
// If it has types, they should be properly formatted
const openBraces = (content.match(/{/g) || []).length;
const closeBraces = (content.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
throw new Error('blogPosts.ts has unbalanced braces');
}
}
});
// Test 21: Verify Astro build succeeds (catches runtime errors)
test('Astro build succeeds without runtime errors', () => {
try {
// Try to build the project - this will catch runtime errors
execSync('npm run build', {
cwd: process.cwd(),
stdio: 'pipe',
timeout: 60000
});
} catch (error: any) {
const stderr = error.stderr?.toString() || '';
const stdout = error.stdout?.toString() || '';
const combined = stderr + stdout;
// Check for common runtime errors
if (combined.includes('children.trim is not a function')) {
throw new Error('Build failed: children.trim error - component children issue');
}
if (combined.includes('is not a function')) {
throw new Error(`Build failed with runtime error: ${combined.substring(0, 200)}`);
}
if (combined.includes('Build failed')) {
throw new Error(`Build failed: ${combined.substring(0, 300)}`);
}
// If it's just a warning, that's okay
if (combined.includes('warning')) {
return;
}
throw new Error(`Build failed for unknown reason: ${combined.substring(0, 200)}`);
}
});
// Test 22: Verify all components can be imported
test('All components can be imported without errors', () => {
const components = [
'src/components/MediumCard.tsx',
'src/components/SearchBar.tsx',
'src/components/ArticleBlockquote.tsx',
'src/components/ArticleHeading.tsx',
'src/components/ArticleParagraph.tsx',
'src/components/ArticleList.tsx',
'src/components/Footer.tsx'
];
for (const component of components) {
const componentPath = path.join(process.cwd(), component);
if (!fs.existsSync(componentPath)) {
throw new Error(`Component ${component} does not exist`);
}
const content = fs.readFileSync(componentPath, 'utf8');
// Check for common issues
if (content.includes('children.trim') && !content.includes('children?.trim')) {
throw new Error(`${component}: uses children.trim without null check`);
}
// Extract JSX content (between return and end of component)
const jsxMatch = content.match(/return\s*\(([\s\S]*?)\)\s*;?\s*\}\s*$/);
if (jsxMatch) {
const jsxContent = jsxMatch[1];
// Only count actual HTML/JSX tags, not TypeScript types
// Filter out tags that are clearly TypeScript (like <HTMLElement>, <string>, etc.)
const openTags = (jsxContent.match(/<([A-Za-z][A-Za-z0-9-]*)(\s|>)/g) || []).length;
const closeTags = (jsxContent.match(/<\/[A-Za-z][A-Za-z0-9-]*>/g) || []).length;
if (openTags !== closeTags) {
throw new Error(`${component}: unbalanced JSX tags (open: ${openTags}, close: ${closeTags})`);
}
}
}
});
// Summary
console.log('\n' + '='.repeat(50));
console.log(`Tests passed: ${passed}`);
console.log(`Tests failed: ${failed}`);
console.log('='.repeat(50));
if (failed === 0) {
console.log('\n🎉 All smoke tests passed! Your blog is ready to use.');
console.log('\nTo start development:');
console.log(' npm run dev');
console.log('\nTo build for production:');
console.log(' npm run build');
process.exit(0);
} else {
console.log('\n❌ Some tests failed. Please check the errors above.');
process.exit(1);
}

178
scripts/test-links.ts Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env tsx
/**
* Simple link checker for the blog
* Tests: All internal links work, no broken references
*/
import fs from 'fs';
import path from 'path';
console.log('🔗 Checking links and references...\n');
let passed = 0;
let failed = 0;
function test(name: string, fn: () => void): void {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
if (error instanceof Error) {
console.log(` Error: ${error.message}`);
}
failed++;
}
}
// Test 1: Check that all referenced blog posts exist
test('All blog post files exist', () => {
const blogDir = path.join(process.cwd(), 'src/pages/blog');
const files = fs.readdirSync(blogDir);
// Get all .astro files
const astroFiles = files.filter(f => f.endsWith('.astro'));
if (astroFiles.length === 0) {
throw new Error('No blog post files found');
}
// Check blogPosts.ts references exist
const blogPostsPath = path.join(process.cwd(), 'src/data/blogPosts.ts');
const content = fs.readFileSync(blogPostsPath, 'utf8');
// Extract slug references
const slugMatches = content.match(/slug:\s*['"]([^'"]+)['"]/g);
if (!slugMatches) {
throw new Error('No slugs found in blogPosts.ts');
}
const slugs = slugMatches.map(m => m.match(/['"]([^'"]+)['"]/)?.[1]);
for (const slug of slugs) {
const expectedFile = `${slug}.astro`;
if (!astroFiles.includes(expectedFile)) {
throw new Error(`Blog post file missing: ${expectedFile} (referenced in blogPosts.ts)`);
}
}
});
// Test 2: Check that tag pages reference valid tags
test('Tag references are valid', () => {
const blogPostsPath = path.join(process.cwd(), 'src/data/blogPosts.ts');
const content = fs.readFileSync(blogPostsPath, 'utf8');
// Extract tags
const tagsMatch = content.match(/tags:\s*\[([^\]]+)\]/);
if (!tagsMatch) {
throw new Error('No tags found in blogPosts.ts');
}
const tagsContent = tagsMatch[1];
const tags = tagsContent.match(/['"]([^'"]+)['"]/g)?.map(t => t.replace(/['"]/g, ''));
if (!tags || tags.length === 0) {
throw new Error('No tags extracted');
}
// Check that tag page exists
const tagPagePath = path.join(process.cwd(), 'src/pages/tags/[tag].astro');
if (!fs.existsSync(tagPagePath)) {
throw new Error('Tag page template missing');
}
});
// Test 3: Check component imports
test('All component imports are valid', () => {
const components = [
'src/components/MediumCard.tsx',
'src/components/SearchBar.tsx',
'src/components/ArticleBlockquote.tsx',
'src/components/ArticleHeading.tsx',
'src/components/ArticleList.tsx',
'src/components/ArticleParagraph.tsx'
];
for (const component of components) {
const componentPath = path.join(process.cwd(), component);
if (!fs.existsSync(componentPath)) {
throw new Error(`Component missing: ${component}`);
}
// Check for export
const content = fs.readFileSync(componentPath, 'utf8');
if (!content.includes('export')) {
throw new Error(`Component has no exports: ${component}`);
}
}
});
// Test 4: Check page structure
test('All required pages exist', () => {
const requiredPages = [
'src/pages/index.astro',
'src/pages/about.astro',
'src/pages/blog.astro',
'src/pages/tags/[tag].astro'
];
for (const page of requiredPages) {
const pagePath = path.join(process.cwd(), page);
if (!fs.existsSync(pagePath)) {
throw new Error(`Required page missing: ${page}`);
}
}
});
// Test 5: Check layout structure
test('Layout files are valid', () => {
const layouts = [
'src/layouts/BaseLayout.astro',
'src/layouts/BlogLayout.astro'
];
for (const layout of layouts) {
const layoutPath = path.join(process.cwd(), layout);
if (!fs.existsSync(layoutPath)) {
throw new Error(`Layout missing: ${layout}`);
}
// Check for basic structure
const content = fs.readFileSync(layoutPath, 'utf8');
if (!content.includes('<slot')) {
throw new Error(`Layout missing slot: ${layout}`);
}
}
});
// Test 6: Check style imports
test('Global styles are properly imported', () => {
const baseLayoutPath = path.join(process.cwd(), 'src/layouts/BaseLayout.astro');
const content = fs.readFileSync(baseLayoutPath, 'utf8');
if (!content.includes('global.css')) {
throw new Error('BaseLayout does not import global.css');
}
const globalCssPath = path.join(process.cwd(), 'src/styles/global.css');
if (!fs.existsSync(globalCssPath)) {
throw new Error('Global CSS file missing');
}
});
// Summary
console.log('\n' + '='.repeat(50));
console.log(`Tests passed: ${passed}`);
console.log(`Tests failed: ${failed}`);
console.log('='.repeat(50));
if (failed === 0) {
console.log('\n🎉 All link checks passed! Your blog structure is solid.');
console.log('\nYour blog is ready to use!');
process.exit(0);
} else {
console.log('\n❌ Some checks failed. Please fix the errors above.');
process.exit(1);
}

View File

@@ -11,16 +11,90 @@ export const Blockquote: React.FC<BlockquoteProps> = ({ children, className = ''
</blockquote>
);
export const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
<pre className={`bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto mb-4 ${className}`}>
<code className="font-mono text-sm">
{children}
</code>
</pre>
);
interface CodeBlockProps {
children: string;
language?: string;
showLineNumbers?: boolean;
className?: string;
}
// Simple syntax highlighting for common languages
const highlightCode = (code: string, language: string): string => {
code = code.trim();
const patterns: Record<string, RegExp[]> = {
comment: [/#[^\n]*/g, /\/\/[^\n]*/g, /\/\*[\s\S]*?\*\//g],
string: [/["'`][^"'`]*["'`]/g],
number: [/\b\d+\b/g],
keyword: [
/\b(def|return|if|else|for|while|import|from|class|try|except|with|as|lambda|yield|async|await|pass|break|continue)\b/g,
/\b(function|const|let|var|if|else|for|while|return|import|export|class|new|try|catch|finally)\b/g,
/\b(package|import|class|public|private|protected|static|void|return|if|else|for|while|try|catch)\b/g
],
function: [/\b[a-zA-Z_]\w*(?=\s*\()/g],
operator: [/[\+\-\*\/=<>!&|]+/g],
punctuation: [/[\[\]{}(),;.:]/g],
tag: [/<\/?[a-zA-Z][^>]*>/g],
attr: [/\b[a-zA-Z-]+(?=\=)/g],
attrValue: [/="[^"]*"/g],
};
let highlighted = code;
const order = ['comment', 'string', 'number', 'keyword', 'function', 'operator', 'punctuation', 'tag', 'attr', 'attrValue'];
order.forEach(type => {
patterns[type].forEach(pattern => {
highlighted = highlighted.replace(pattern, match => {
return `<span class="token ${type}">${match}</span>`;
});
});
});
return highlighted;
};
export const CodeBlock: React.FC<CodeBlockProps> = ({
children,
language = 'text',
showLineNumbers = false,
className = ''
}) => {
const code = (typeof children === 'string' ? children : String(children)).trim();
const highlighted = language !== 'text' ? highlightCode(code, language) : code;
const lines = code.split('\n');
return (
<div className="relative my-6">
{language !== 'text' && (
<div className="absolute top-2 right-2 text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded font-sans">
{language}
</div>
)}
<pre
className={`bg-slate-900 text-slate-100 p-5 rounded-lg overflow-x-auto border border-slate-700 font-mono text-sm leading-relaxed ${className} ${showLineNumbers ? 'pl-12' : ''}`}
>
{showLineNumbers ? (
<div className="relative">
<div className="absolute left-0 top-0 bottom-0 w-8 text-right text-slate-600 select-none pr-3 border-r border-slate-700">
{lines.map((_, i) => (
<div key={i} className="leading-relaxed">{i + 1}</div>
))}
</div>
<div className="pl-10">
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</div>
</div>
) : (
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
)}
</pre>
</div>
);
};
export const InlineCode: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
<code className={`bg-slate-200 text-slate-900 px-1 py-0.5 rounded font-mono text-sm ${className}`}>
<code className={`bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded font-mono text-sm border border-pink-200 ${className}`}>
{children}
</code>
);

View File

@@ -1,48 +0,0 @@
import React from 'react';
interface BlogPostCardProps {
title: string;
description: string;
date: string;
slug: string;
tags?: string[];
}
export const BlogPostCard: React.FC<BlogPostCardProps> = ({
title,
description,
date,
slug,
tags = []
}) => {
const formattedDate = new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return (
<article className="border border-slate-200 rounded-lg p-6 hover:shadow-lg transition-shadow">
<a href={`/blog/${slug}`} className="block">
<h2 className="text-2xl font-bold text-slate-900 mb-2 hover:text-slate-700">
{title}
</h2>
<p className="text-slate-600 mb-3 leading-relaxed">
{description}
</p>
<div className="flex items-center justify-between text-sm text-slate-500">
<time dateTime={date}>{formattedDate}</time>
{tags.length > 0 && (
<div className="flex gap-2">
{tags.map(tag => (
<span key={tag} className="bg-slate-100 text-slate-700 px-2 py-1 rounded">
{tag}
</span>
))}
</div>
)}
</div>
</a>
</article>
);
};

View File

@@ -1,23 +0,0 @@
import React from 'react';
interface ContainerProps {
children: React.ReactNode;
className?: string;
maxWidth?: string;
}
export const Container: React.FC<ContainerProps> = ({
children,
className = '',
maxWidth = 'max-w-4xl'
}) => (
<div className={`mx-auto px-4 ${maxWidth} ${className}`}>
{children}
</div>
);
export const ArticleContainer: React.FC<ContainerProps> = ({ children, className = '' }) => (
<Container maxWidth="max-w-3xl" className={`py-8 ${className}`}>
{children}
</Container>
);

36
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react';
export const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t border-slate-200 py-12 mt-20 bg-gradient-to-b from-white to-slate-50">
<div className="max-w-3xl mx-auto px-6">
{/* Main footer content - all centered */}
<div className="flex flex-col items-center justify-center gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-6 h-6 bg-slate-900 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">M</span>
</div>
<span className="text-sm text-slate-900 font-medium">Marc Mintel</span>
</div>
<p className="text-sm text-slate-600 font-serif italic text-center max-w-md">
Write things down. Don't forget. Maybe help someone.
</p>
<div className="flex items-center gap-3 text-sm">
<span className="text-slate-500 font-sans">© {currentYear}</span>
</div>
</div>
{/* Subtle tagline */}
<div className="text-center pt-6 border-t border-slate-200/50">
<p className="text-xs text-slate-400 font-sans">
A public notebook of digital problem solving
</p>
</div>
</div>
</footer>
);
};

54
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { BookOpen, Code2, Terminal, Wrench } from 'lucide-react';
import React from 'react';
export const Hero: React.FC = () => {
return (
<section className="relative bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white py-20 md:py-28 overflow-hidden">
{/* Background pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
backgroundSize: '40px 40px'
}} />
</div>
<div className="container relative z-10">
<div className="max-w-4xl mx-auto text-center">
{/* Main heading */}
<h1 className="text-4xl md:text-6xl font-bold mb-6 animate-slide-up">
Digital Problem Solver
</h1>
<p className="text-xl md:text-2xl text-slate-300 mb-8 leading-relaxed animate-fade-in">
I work on Digital problems and build tools, scripts, and systems to solve them.
</p>
{/* Quick stats or focus areas */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10 animate-fade-in">
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<Code2 className="mx-auto mb-2 text-primary-400" size={24} />
<div className="text-sm font-mono text-slate-300">Code</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<Wrench className="mx-auto mb-2 text-primary-400" size={24} />
<div className="text-sm font-mono text-slate-300">Tools</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<Terminal className="mx-auto mb-2 text-primary-400" size={24} />
<div className="text-sm font-mono text-slate-300">Automation</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<BookOpen className="mx-auto mb-2 text-primary-400" size={24} />
<div className="text-sm font-mono text-slate-300">Learning</div>
</div>
</div>
{/* Topics */}
<div className="mt-8 text-sm text-slate-400 animate-fade-in">
<span className="font-semibold text-slate-300">Topics:</span> Vibe coding with AI Debugging Mac tools Automation Small scripts Learning notes FOSS
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,130 @@
---
import Tag from './Tag.astro';
interface Props {
post: {
title: string;
description: string;
date: string;
slug: string;
tags?: string[];
};
}
const { post } = Astro.props;
const { title, description, date, slug, tags = [] } = post;
const formattedDate = new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: '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}
</h3>
<span class="text-xs text-slate-500 font-sans whitespace-nowrap flex-shrink-0">
{formattedDate}
</span>
</div>
<p class="text-slate-600 leading-relaxed font-serif text-sm mb-3 line-clamp-3">
{description}
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500 font-sans">
{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">
{tag}
</span>
))}
</div>
)}
</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 {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 0;
height: 1px;
background: #3b82f6;
transition: width 0.2s ease;
}
.post-card:hover h3::after {
width: 100%;
}
/* Line clamp */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 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>

View File

@@ -0,0 +1,141 @@
import React, { useState, useRef, useEffect } from 'react';
export const SearchBar: React.FC = () => {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
// Trigger search functionality
if (typeof window !== 'undefined') {
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
if (!allPosts) return;
const queryLower = value.toLowerCase().trim();
allPosts.forEach((post: HTMLElement) => {
const title = post.querySelector('h3')?.textContent?.toLowerCase() || '';
const description = post.querySelector('.post-excerpt')?.textContent?.toLowerCase() || '';
const tags = Array.from(post.querySelectorAll('.highlighter-tag'))
.map(tag => tag.textContent?.toLowerCase() || '')
.join(' ');
const searchableText = `${title} ${description} ${tags}`;
if (searchableText.includes(queryLower) || queryLower === '') {
post.style.display = 'block';
post.style.opacity = '1';
post.style.transform = 'scale(1)';
} else {
post.style.display = 'none';
post.style.opacity = '0';
post.style.transform = 'scale(0.95)';
}
});
}
};
const clearSearch = () => {
setQuery('');
if (inputRef.current) {
inputRef.current.value = '';
inputRef.current.focus();
}
// Reset all posts
if (typeof window !== 'undefined') {
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
allPosts?.forEach((post: HTMLElement) => {
post.style.display = 'block';
post.style.opacity = '1';
post.style.transform = 'scale(1)';
});
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
clearSearch();
}
};
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-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'
}`}
onInput={handleInput}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
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"
aria-label="Clear search"
>
<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>
</button>
)}
</div>
{/* Search hint */}
<div className="hidden md:flex items-center gap-2 ml-3 text-xs text-slate-500 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>
);
};

125
src/components/Tag.astro Normal file
View File

@@ -0,0 +1,125 @@
---
interface Props {
tag: string;
index: number;
className?: string;
}
const { tag, index, className = '' } = Astro.props;
// Color mapping based on tag content for variety
const getColorClass = (tag: string) => {
const tagLower = tag.toLowerCase();
if (tagLower.includes('meta') || tagLower.includes('learning')) return 'highlighter-yellow';
if (tagLower.includes('debug') || tagLower.includes('tools')) return 'highlighter-pink';
if (tagLower.includes('ai') || tagLower.includes('automation')) return 'highlighter-blue';
if (tagLower.includes('script') || tagLower.includes('code')) return 'highlighter-green';
return 'highlighter-yellow'; // default
};
const colorClass = getColorClass(tag);
---
<span class={`highlighter-tag ${colorClass} ${className} inline-block text-xs font-bold px-2.5 py-1 rounded cursor-pointer transition-all duration-200 relative overflow-hidden group`} data-tag={tag}>
<span class="relative z-10">{tag}</span>
<!-- 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>
/* Enhanced hover effects */
.highlighter-tag {
transform: rotate(-1deg) translateY(0);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
}
.highlighter-tag:hover {
transform: rotate(-2deg) translateY(-2px) scale(1.05);
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.15);
}
/* Staggered entrance animation */
@keyframes tagPopIn {
from {
opacity: 0;
transform: rotate(-1deg) scale(0.8) translateY(5px);
}
to {
opacity: 1;
transform: rotate(-1deg) scale(1) translateY(0);
}
}
.highlighter-tag {
animation: tagPopIn 0.3s ease-out both;
animation-delay: calc(var(--tag-index, 0) * 0.05s);
}
/* Color variations with gradients */
.highlighter-yellow {
background: linear-gradient(135deg, rgba(255, 235, 59, 0.95) 0%, rgba(255, 213, 79, 0.95) 100%);
color: #3f2f00;
}
.highlighter-pink {
background: linear-gradient(135deg, rgba(255, 167, 209, 0.95) 0%, rgba(255, 122, 175, 0.95) 100%);
color: #3f0018;
}
.highlighter-green {
background: linear-gradient(135deg, rgba(129, 199, 132, 0.95) 0%, rgba(102, 187, 106, 0.95) 100%);
color: #002f0a;
}
.highlighter-blue {
background: linear-gradient(135deg, rgba(100, 181, 246, 0.95) 0%, rgba(66, 165, 245, 0.95) 100%);
color: #001f3f;
}
/* Hover glow effect */
.highlighter-tag:hover::before {
content: '';
position: absolute;
inset: -2px;
background: inherit;
filter: blur(8px);
opacity: 0.4;
z-index: -1;
border-radius: inherit;
}
/* Click effect */
.highlighter-tag:active {
transform: rotate(-1deg) translateY(0) scale(0.98);
transition: transform 0.1s ease;
}
/* Focus effect for accessibility */
.highlighter-tag:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
transform: rotate(-1deg) translateY(-2px) scale(1.05);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3), 3px 3px 0 rgba(0, 0, 0, 0.15);
}
</style>
<script>
// Add click handler for tag filtering
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
const tags = document.querySelectorAll('.highlighter-tag');
tags.forEach(tagElement => {
tagElement.addEventListener('click', (e) => {
const tag = tagElement.getAttribute('data-tag');
if (tag && typeof (window as any).filterByTag === 'function') {
(window as any).filterByTag(tag);
}
});
});
});
}
</script>

View File

@@ -1,18 +1,14 @@
---
import '../styles/global.css';
import { Footer } from '../components/Footer';
import { Hero } from '../components/Hero';
interface Props {
title: string;
description?: string;
}
const { title, description = "Technical problem solver's blog - practical insights and learning notes" } = Astro.props;
// About info from context
const aboutInfo = {
name: "Marc Mintel",
role: "Technical problem solver",
email: "marc@mintel.me",
location: "Vulkaneifel, Germany"
};
---
<!DOCTYPE html>
@@ -20,81 +16,166 @@ const aboutInfo = {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} | {aboutInfo.name}</title>
<title>{title} | Marc Mintel</title>
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="bg-white text-slate-900 font-sans antialiased">
<div class="min-h-screen flex flex-col">
<!-- Header with About Info -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-10">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-slate-900">
<a href="/" class="hover:text-slate-700">{aboutInfo.name}</a>
</h1>
<p class="text-sm text-slate-600">{aboutInfo.role}</p>
</div>
<div class="text-right text-sm text-slate-600 hidden sm:block">
<p>{aboutInfo.email}</p>
<p>{aboutInfo.location}</p>
</div>
</div>
</div>
</header>
<body>
<!-- Single page container -->
<div class="min-h-screen bg-white">
<!-- Main Content -->
<main class="flex-grow">
<main class="container">
<slot />
</main>
<!-- Footer -->
<footer class="border-t border-slate-200 bg-slate-50">
<div class="max-w-4xl mx-auto px-4 py-6">
<p class="text-sm text-slate-600 text-center">
A public notebook of things I figured out, mistakes I made, and tools I tested.
</p>
</div>
</footer>
<Footer client:load />
<!-- Global Interactive Elements -->
<!-- Reading Progress Bar -->
<div id="global-reading-progress" class="reading-progress-bar" style="display: none;"></div>
<!-- Floating Back to Top Button -->
<button
id="global-back-to-top"
class="floating-back-to-top"
aria-label="Back to top"
style="display: none;"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
</div>
<style is:global>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
<!-- Global JavaScript for interactive elements -->
<script>
// Global interactive elements manager
class GlobalInteractive {
readingProgress: HTMLElement | null;
backToTop: HTMLElement | null;
constructor() {
this.readingProgress = document.getElementById('global-reading-progress');
this.backToTop = document.getElementById('global-back-to-top');
this.init();
}
init() {
// Set up event listeners
window.addEventListener('scroll', () => this.handleScroll());
// Back to top click
this.backToTop?.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
// Show elements on first interaction
document.addEventListener('click', () => this.showElements(), { once: true });
document.addEventListener('scroll', () => this.showElements(), { once: true });
}
showElements() {
if (this.readingProgress) {
this.readingProgress.style.display = 'block';
}
if (this.backToTop) {
this.backToTop.style.display = 'flex';
}
}
handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
// Update reading progress
if (this.readingProgress && docHeight > 0) {
const progress = (scrollTop / docHeight) * 100;
this.readingProgress.style.transform = `scaleX(${progress / 100})`;
}
// Show/hide back to top button
if (this.backToTop) {
if (scrollTop > 300) {
this.backToTop.classList.add('visible');
} else {
this.backToTop.classList.remove('visible');
}
}
}
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
// Initialize when DOM is ready
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
new GlobalInteractive();
});
}
</script>
<style>
/* Additional global styles */
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Smooth scrolling */
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Focus styles for accessibility */
a:focus,
button:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Selection color */
::selection {
background: #bfdbfe;
color: #1e40af;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Print styles */
@media print {
.floating-back-to-top,
.keyboard-hint,
.reading-progress-bar {
display: none !important;
}
}
</style>
</body>
</html>
</html>

View File

@@ -1,133 +0,0 @@
---
import type { CollectionEntry } from 'astro:content';
import BaseLayout from './BaseLayout.astro';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { title, description, date, tags } = post.data;
const formattedDate = new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
---
<BaseLayout title={title} description={description}>
<article class="max-w-3xl mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-4xl font-bold text-slate-900 mb-4 leading-tight">
{title}
</h1>
<div class="flex items-center justify-between text-sm text-slate-500 mb-4">
<time datetime={date}>{formattedDate}</time>
{tags && tags.length > 0 && (
<div class="flex gap-2">
{tags.map(tag => (
<span key={tag} class="bg-slate-100 text-slate-700 px-2 py-1 rounded text-xs">
{tag}
</span>
))}
</div>
)}
</div>
<p class="text-xl text-slate-600 leading-relaxed">
{description}
</p>
</header>
<div class="prose prose-slate max-w-none">
<slot />
</div>
</article>
</BaseLayout>
<style>
.prose {
line-height: 1.75;
}
.prose h2 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 2rem;
margin-bottom: 1rem;
color: #0f172a;
line-height: 1.3;
}
.prose h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #0f172a;
line-height: 1.4;
}
.prose p {
margin-bottom: 1.25rem;
color: #334155;
}
.prose ul, .prose ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose li {
margin-bottom: 0.5rem;
color: #334155;
}
.prose ul li {
list-style-type: disc;
}
.prose ol li {
list-style-type: decimal;
}
.prose blockquote {
border-left: 4px solid #94a3b8;
padding-left: 1rem;
font-style: italic;
color: #475569;
margin: 1.5rem 0;
}
.prose code {
background-color: #f1f5f9;
color: #0f172a;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Courier New', monospace;
}
.prose pre {
background-color: #0f172a;
color: #f1f5f9;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1.25rem;
}
.prose pre code {
background: none;
color: inherit;
padding: 0;
}
.prose a {
color: #2563eb;
text-decoration: underline;
}
.prose a:hover {
color: #1d4ed8;
}
</style>

716
src/pages/blog/[slug].astro Normal file
View File

@@ -0,0 +1,716 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Tag from '../../components/Tag.astro';
import { blogPosts } from '../../data/blogPosts';
import { CodeBlock, InlineCode } from '../../components/ArticleBlockquote';
import { H2, H3 } from '../../components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../components/ArticleParagraph';
import { UL, LI } from '../../components/ArticleList';
export async function getStaticPaths() {
return blogPosts.map(post => ({
params: { slug: post.slug },
props: { post }
}));
}
const { post } = Astro.props;
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
// Calculate reading time
const wordCount = post.description.split(/\s+/).length + 100;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
// Generate unique clap key for this post
const clapKey = `claps_${post.slug}`;
---
<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">
<button
id="back-btn-top"
class="flex items-center gap-2 text-slate-700 hover:text-blue-600 transition-colors duration-200 group"
aria-label="Back to home"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span class="font-medium">Back</span>
</button>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500 font-sans hidden sm:inline">
{readingTime} min read
</span>
<button
id="share-btn-top"
class="share-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-blue-50 border border-slate-200 hover:border-blue-300 rounded-full transition-all duration-200"
aria-label="Share this post"
>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
</button>
<button
id="clap-btn-top"
class="clap-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-blue-50 border border-slate-200 hover:border-blue-300 rounded-full transition-all duration-200"
aria-label="Clap for this post"
data-clap-key={clapKey}
>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-600 transition-colors" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.789l5 2.5a2 2 0 001.794 0l5-2.5A2 2 0 0019 15.762v-5.43a2.5 2.5 0 00-1.292-2.182l-4.4-2.2a1.5 1.5 0 00-1.333 0l-4.4 2.2A2.5 2.5 0 002 10.333z"/>
</svg>
<span id="clap-count-top" class="text-sm font-medium text-slate-700">0</span>
</button>
</div>
</div>
</nav>
<!-- Main content with enhanced animations -->
<main id="post-content" class="pt-24">
<!-- Beautiful hero section -->
<section class="py-12 md:py-16">
<div class="max-w-3xl mx-auto px-6">
<div class="text-center">
<!-- Title -->
<h1
id="article-title"
class="text-3xl md:text-5xl font-serif font-bold text-slate-900 mb-6 leading-tight tracking-tight cursor-default"
>
{post.title}
</h1>
<!-- Elegant meta info -->
<div
id="article-meta"
class="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 mb-6 font-sans"
>
<time datetime={post.date} class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zM4 8h12v8H4V8z"/>
</svg>
{formattedDate}
</time>
<span class="text-slate-400">•</span>
<span class="flex items-center gap-1.5 px-3 py-1 bg-slate-50 rounded-full hover:bg-slate-100 transition-colors duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
{readingTime} min
</span>
</div>
<!-- Description with elegant styling -->
<p
id="article-description"
class="text-xl md:text-2xl text-slate-600 leading-relaxed font-serif italic mb-8 max-w-2xl mx-auto"
>
{post.description}
</p>
<!-- Tags using the Tag component -->
{post.tags && post.tags.length > 0 && (
<div id="article-tags" class="flex flex-wrap justify-center gap-2 mb-8">
{post.tags.map((tag: string, index: number) => (
<Tag tag={tag} index={index} className="text-xs" />
))}
</div>
)}
</div>
</div>
</section>
<!-- Article content with enhanced typography -->
<section class="max-w-3xl mx-auto px-6 pb-24">
<div class="prose prose-slate max-w-none">
<p>{post.description}</p>
{post.slug === 'first-note' && (
<>
<LeadParagraph>
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
</LeadParagraph>
<H2>Why write in public?</H2>
<Paragraph>
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
</Paragraph>
<H2>What to expect</H2>
<UL>
<LI>Short entries, usually under 500 words</LI>
<LI>Practical solutions to specific problems</LI>
<LI>Notes on tools and workflows</LI>
<LI>Mistakes and what I learned</LI>
</UL>
</>
)}
{post.slug === 'debugging-tips' && (
<>
<LeadParagraph>
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
</LeadParagraph>
<H2>Why print statements work</H2>
<Paragraph>
Debuggers are powerful, but they change how your code runs. Print statements don't.
</Paragraph>
<CodeBlock language="python" showLineNumbers={true}>
{`def process_data(data):
print(f"Processing {len(data)} items")
result = expensive_operation(data)
print(f"Operation result: {result}")
return result`}
</CodeBlock>
</>
)}
</div>
<!-- Footer with elegant back button -->
<div class="mt-16 pt-8 border-t border-slate-200 text-center">
<button
id="back-btn-bottom"
class="inline-flex items-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-full hover:border-blue-400 hover:text-blue-600 hover:shadow-md transition-all duration-300 font-medium group"
>
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</button>
</div>
</section>
</main>
<!-- 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() {
const section = document.querySelector('section:last-child') as HTMLElement;
const progressBar = document.getElementById('reading-progress');
if (!section || !progressBar) return;
const sectionTop = section.offsetTop;
const sectionHeight = section.offsetHeight;
const windowHeight = window.innerHeight;
const scrollTop = window.scrollY;
const progress = Math.min(
Math.max((scrollTop - sectionTop + windowHeight * 0.3) / (sectionHeight - windowHeight * 0.3), 0),
1
);
progressBar.style.width = `${progress * 100}%`;
// Update top nav appearance on scroll
const topNav = document.getElementById('top-nav');
if (topNav) {
if (scrollTop > 100) {
topNav.style.backdropFilter = 'blur(12px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
} else {
topNav.style.backdropFilter = 'blur(8px)';
topNav.style.backgroundColor = 'rgba(255, 255, 255, 0.8)';
}
}
}
// Back to top button
function updateBackToTop() {
const backToTopBtn = document.getElementById('back-to-top');
if (!backToTopBtn) return;
if (window.scrollY > 300) {
backToTopBtn.style.opacity = '1';
backToTopBtn.style.pointerEvents = 'auto';
} else {
backToTopBtn.style.opacity = '0';
backToTopBtn.style.pointerEvents = 'none';
}
}
// Back to home with lovely transition
function setupBackNavigation() {
const backButtons = [
document.getElementById('back-btn-top'),
document.getElementById('back-btn-bottom')
];
const goHome = () => {
// Lovely exit animation
const content = document.getElementById('post-content');
if (content) {
content.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
content.style.opacity = '0';
content.style.transform = 'translateY(20px) scale(0.98)';
}
// Fade out top nav
const topNav = document.getElementById('top-nav');
if (topNav) {
topNav.style.transition = 'opacity 0.4s ease-out';
topNav.style.opacity = '0';
}
// Create beautiful overlay
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 bg-gradient-to-br from-white via-slate-50 to-white z-[100] opacity-0 transition-opacity duration-500';
document.body.appendChild(overlay);
setTimeout(() => {
overlay.style.opacity = '1';
}, 100);
// Navigate after animation
setTimeout(() => {
window.location.href = '/?from=post';
}, 500);
};
backButtons.forEach(btn => {
if (btn) btn.addEventListener('click', goHome);
});
// Back to top click
const backToTopBtn = document.getElementById('back-to-top');
if (backToTopBtn) {
backToTopBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
}
// Production-ready clap functionality with localStorage
function setupClapButtons() {
const clapBtnTop = document.getElementById('clap-btn-top');
const clapCountTop = document.getElementById('clap-count-top');
if (!clapBtnTop || !clapCountTop) return;
// Get clap key from data attribute
const clapKey = clapBtnTop.getAttribute('data-clap-key');
if (!clapKey) return;
// Load existing claps from localStorage
let claps = parseInt(localStorage.getItem(clapKey) || '0');
clapCountTop.textContent = claps.toString();
// Visual state if already clapped
if (claps > 0) {
clapBtnTop.classList.add('bg-blue-50', 'border-blue-300');
}
clapBtnTop.addEventListener('click', () => {
// Increment claps
claps++;
localStorage.setItem(clapKey, claps.toString());
clapCountTop.textContent = claps.toString();
// Visual feedback
clapBtnTop.classList.add('scale-110', 'bg-blue-100', 'border-blue-300');
setTimeout(() => {
clapBtnTop.classList.remove('scale-110', 'bg-blue-100', 'border-blue-300');
}, 300);
// Ripple effect
const ripple = document.createElement('span');
ripple.className = 'absolute inset-0 bg-blue-400/20 rounded-full scale-0 animate-ripple';
clapBtnTop.style.position = 'relative';
clapBtnTop.style.overflow = 'hidden';
clapBtnTop.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
}
// Share function - Web Share API or modal with share links
function setupShareButton() {
const shareBtn = document.getElementById('share-btn-top');
if (!shareBtn) return;
const url = window.location.href;
const title = document.title;
// Check if Web Share API is supported
if (navigator.share) {
// Use native share
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({
title: title,
url: url
});
// Success feedback
shareBtn.classList.add('bg-green-50', 'border-green-300');
setTimeout(() => {
shareBtn.classList.remove('bg-green-50', 'border-green-300');
}, 1000);
} catch (err: unknown) {
// User cancelled - no feedback needed
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
} else {
// Show modal with share links
shareBtn.addEventListener('click', () => {
showShareModal(title, url);
});
}
}
// Show share modal with links
function showShareModal(title: string, url: string) {
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
// Create modal
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-[200] flex items-center justify-center p-4';
modal.innerHTML = `
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity" id="share-modal-backdrop"></div>
<div class="relative bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all scale-100" id="share-modal-content">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-900">Share this post</h3>
<button id="close-share-modal" class="p-2 hover:bg-slate-100 rounded-full transition-colors" aria-label="Close">
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3">
<a href="mailto:?subject=${encodedTitle}&body=${encodedUrl}"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">Email</div>
<div class="text-sm text-slate-500">Send via email</div>
</div>
</a>
<a href="https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-sky-50 rounded-full flex items-center justify-center group-hover:bg-sky-100 transition-colors">
<svg class="w-5 h-5 text-sky-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">X (Twitter)</div>
<div class="text-sm text-slate-500">Share on X</div>
</div>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group">
<div class="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-900">LinkedIn</div>
<div class="text-sm text-slate-500">Share professionally</div>
</div>
</a>
<button id="copy-link-btn"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors group w-full text-left"
title="${url}">
<div class="w-10 h-10 bg-slate-50 rounded-full flex items-center justify-center group-hover:bg-slate-100 transition-colors">
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-slate-900">Copy Link</div>
<div class="text-sm text-slate-500 truncate" id="copy-link-text">${url}</div>
</div>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.body.style.overflow = 'hidden';
// Close modal handlers
const closeBtn = modal.querySelector('#close-share-modal');
const backdrop = modal.querySelector('#share-modal-backdrop');
const copyBtn = modal.querySelector('#copy-link-btn');
const closeModal = () => {
const content = modal.querySelector('#share-modal-content') as HTMLElement;
if (content) {
content.style.transform = 'scale(0.95)';
content.style.opacity = '0';
}
if (backdrop) {
(backdrop as HTMLElement).style.opacity = '0';
}
setTimeout(() => {
modal.remove();
document.body.style.overflow = '';
}, 200);
};
if (closeBtn) closeBtn.addEventListener('click', closeModal);
if (backdrop) backdrop.addEventListener('click', closeModal);
// Copy link functionality
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
const displayText = document.getElementById('copy-link-text');
const originalText = displayText?.textContent || '';
try {
await navigator.clipboard.writeText(url);
if (displayText) {
displayText.textContent = '✓ Copied!';
displayText.classList.add('text-green-600');
setTimeout(() => {
displayText.textContent = originalText;
displayText.classList.remove('text-green-600');
}, 2000);
}
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (displayText) {
displayText.textContent = '✓ Copied!';
displayText.classList.add('text-green-600');
setTimeout(() => {
displayText.textContent = originalText;
displayText.classList.remove('text-green-600');
}, 2000);
}
} catch (err2) {
alert('Could not copy link. Please copy manually: ' + url);
}
document.body.removeChild(textArea);
}
});
}
}
// Initialize all
document.addEventListener('DOMContentLoaded', () => {
updateReadingProgress();
updateBackToTop();
setupBackNavigation();
setupClapButtons();
setupShareButton();
window.addEventListener('scroll', () => {
updateReadingProgress();
updateBackToTop();
});
});
</script>
<style>
/* Enhanced typography */
.prose-slate {
color: #334155;
}
.prose-slate p {
margin-bottom: 1.75rem;
line-height: 1.85;
font-size: 1.125rem;
}
.prose-slate h2 {
margin-top: 2.75rem;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.prose-slate h3 {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.prose-slate ul,
.prose-slate ol {
margin-bottom: 1.75rem;
padding-left: 1.5rem;
}
.prose-slate li {
margin-bottom: 0.55rem;
line-height: 1.75;
}
.prose-slate blockquote {
border-left: 4px solid #cbd5e1;
padding-left: 1.5rem;
font-style: italic;
color: #475569;
margin: 1.75rem 0;
font-size: 1.125rem;
background: linear-gradient(to right, #f8fafc, #ffffff);
padding: 1rem 1.5rem 1rem 1.5rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.prose-slate code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
color: #dc2626;
font-family: 'JetBrains Mono', monospace;
}
/* Smooth transitions */
a, button {
transition: all 0.2s ease;
}
/* Focus styles for all interactive elements */
a:focus,
button:focus,
.clap-button-top:focus,
.share-button-top:focus,
#back-btn-top:focus,
#back-btn-bottom:focus,
#back-to-top:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* High contrast focus for better visibility */
@media (prefers-contrast: high) {
a:focus,
button:focus {
outline: 3px solid #000;
outline-offset: 2px;
}
}
/* Reading progress */
#reading-progress {
transition: width 0.1s ease-out;
}
/* Top nav transitions */
#top-nav {
transition: backdrop-filter 0.3s ease, background-color 0.3s ease, opacity 0.4s ease;
}
/* Back to top hover */
#back-to-top:hover {
transform: translateY(-2px) scale(1.05);
}
/* Clap button animations */
.clap-button-top {
position: relative;
overflow: hidden;
}
/* Ripple animation */
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
.animate-ripple {
animation: ripple 0.6s ease-out;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #94a3b8, #64748b);
}
/* Selection styling */
::selection {
background: linear-gradient(to right, #3b82f6, #8b5cf6);
color: white;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Share modal animations */
#share-modal-backdrop {
transition: opacity 0.2s ease;
}
#share-modal-content {
transition: transform 0.2s ease, opacity 0.2s ease;
}
</style>
</BaseLayout>

View File

@@ -1,79 +0,0 @@
---
import BlogLayout from '../../layouts/BlogLayout.astro';
import { H2, H3 } from '../../components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../components/ArticleParagraph';
import { UL, LI } from '../../components/ArticleList';
import { CodeBlock, InlineCode } from '../../components/ArticleBlockquote';
const post = {
data: {
title: "Debugging with print statements",
description: "When printf debugging is actually the right tool",
date: new Date("2024-01-20"),
tags: ["debugging", "tools"]
}
};
---
<BlogLayout post={post}>
<LeadParagraph>
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
</LeadParagraph>
<H2>Why print statements work</H2>
<Paragraph>
Debuggers are powerful, but they change how your code runs. Print statements don't. They let you see what's actually happening in the real execution flow.
</Paragraph>
<H3>When to use them</H3>
<UL>
<LI>Quick investigation of unexpected behavior</LI>
<LI>Understanding data flow through multiple functions</LI>
<LI>Checking state at specific points in time</LI>
<LI>When setting up a debugger feels like overkill</LI>
</UL>
<H2>Make them useful</H2>
<Paragraph>
Bad print statements create noise. Good ones tell you exactly what you need to know.
</Paragraph>
<CodeBlock>
{`def process_data(data):
# Bad: What does this even mean?
print("debug 1")
# Good: Clear context and value
print(f"Processing {len(data)} items")
result = expensive_operation(data)
# Good: Show the important intermediate result
print(f"Operation result: {result}")
return result`}
</CodeBlock>
<H3>What to print</H3>
<UL>
<LI>Variable values at key points</LI>
<LI>Function entry/exit with parameters</LI>
<LI>Loop iterations with counters</LI>
<LI>Conditional branches taken</LI>
<LI>Timing information for performance</LI>
</UL>
<H2>Temporary by design</H2>
<Paragraph>
The beauty of print statements is that they're temporary. Add them, get your answer, remove them. No setup, no cleanup, no commitment.
</Paragraph>
<Paragraph>
Sometimes the most sophisticated tool is the one you can use in 5 seconds and throw away in 10.
</Paragraph>
</BlogLayout>

View File

@@ -1,69 +0,0 @@
---
import BlogLayout from '../../layouts/BlogLayout.astro';
import { H2, H3 } from '../../components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../components/ArticleParagraph';
import { UL, LI } from '../../components/ArticleList';
import { Blockquote, InlineCode } from '../../components/ArticleBlockquote';
const post = {
data: {
title: "Starting this blog",
description: "Why I'm writing things down in public",
date: new Date("2024-01-15"),
tags: ["meta", "learning"]
}
};
---
<BlogLayout post={post}>
<LeadParagraph>
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
</LeadParagraph>
<H2>Why write in public?</H2>
<Paragraph>
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
</Paragraph>
<Paragraph>
The goal isn't to teach or impress. It's to document. If you find something useful here, great. If not, that's fine too.
</Paragraph>
<H2>What to expect</H2>
<UL>
<LI>Short entries, usually under 500 words</LI>
<LI>Practical solutions to specific problems</LI>
<LI>Notes on tools and workflows</LI>
<LI>Mistakes and what I learned</LI>
<LI>Occasional deep dives when needed</LI>
</UL>
<H2>How I work</H2>
<Paragraph>
My process is simple:
</Paragraph>
<UL>
<LI>I try things</LI>
<LI>I break things</LI>
<LI>I fix things</LI>
<LI>I write down what I learned</LI>
</UL>
<Blockquote>
Understanding doesn't expire. Finished projects do.
</Blockquote>
<H3>Tools I use</H3>
<Paragraph>
Mostly standard Unix tools, Python, shell scripts, and whatever gets the job done. I prefer <InlineCode>simple</InlineCode> over <InlineCode>clever</InlineCode>.
</Paragraph>
<Paragraph>
If you're building things and solving problems, maybe you'll find something useful here. If not, thanks for reading anyway.
</Paragraph>
</BlogLayout>

View File

@@ -1,49 +1,398 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { BlogPostCard } from '../components/BlogPostCard';
import MediumCard from '../components/MediumCard.astro';
import { SearchBar } from '../components/SearchBar';
import Tag from '../components/Tag.astro';
import { blogPosts } from '../data/blogPosts';
// Sort posts by date
const posts = [...blogPosts].sort((a, b) =>
const allPosts = [...blogPosts].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Get unique tags
const allTags = [...new Set(allPosts.flatMap(post => post.tags))];
---
<BaseLayout title="Blog" description="Technical problem solving blog - practical insights and learning notes">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- About Section -->
<section class="mb-12 pb-8 border-b border-slate-200">
<p class="text-lg text-slate-700 leading-relaxed">
I work on technical problems and build tools, scripts, and systems to solve them.
Sometimes that means code, sometimes automation, sometimes AI, sometimes something else.
The tool is secondary. The problem comes first.
</p>
<p class="text-sm text-slate-500 mt-4">
Topics: Vibe coding with AI • Debugging • Mac tools • Automation • Small scripts • Learning notes • FOSS
</p>
</section>
<BaseLayout title="Home" description="A public notebook of technical problem solving, mistakes, and learning notes">
<!-- Everything on ONE minimalist page -->
<!-- Blog Posts -->
<section>
<h2 class="text-2xl font-bold text-slate-900 mb-6">Recent Notes</h2>
{posts.length === 0 ? (
<div class="text-center py-12">
<p class="text-slate-500">No posts yet. Check back soon!</p>
<!-- Clean Hero Section -->
<section class="py-16 md:py-20">
<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">
Marc Mintel
</h1>
<p class="text-lg md:text-xl 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>
Vulkaneifel, Germany
</span>
<span>•</span>
<span>Digital problem solver</span>
</div>
</div>
</div>
</section>
<!-- Search -->
<section class="mb-8 mt-8">
<div id="search-container">
<SearchBar />
</div>
</section>
<!-- Topics -->
{allTags.length > 0 && (
<section class="mb-8">
<h2 class="text-lg font-semibold text-slate-800 mb-4">Topics</h2>
<div class="tag-cloud">
{allTags.map((tag, index) => (
<a
href="#"
data-tag={tag}
onclick={`filterByTag('${tag}'); return false;`}
class="inline-block"
>
<Tag tag={tag} index={index} />
</a>
))}
</div>
</section>
)}
<!-- All Posts -->
<section>
<div id="posts-container" class="grid-responsive">
{allPosts.length === 0 ? (
<div class="empty-state">
<p>No posts yet. Check back soon!</p>
</div>
) : (
<div class="space-y-4">
{posts.map(post => (
<BlogPostCard
title={post.title}
description={post.description}
date={post.date}
slug={post.slug}
tags={post.tags}
/>
))}
</div>
allPosts.map(post => (
<a
href={`/blog/${post.slug}`}
class="post-link"
data-slug={post.slug}
data-title={post.title}
data-description={post.description}
data-date={post.date}
data-tags={post.tags?.join(',')}
>
<MediumCard post={post} />
</a>
))
)}
</section>
</div>
</BaseLayout>
</div>
</section>
<!-- Transition overlay for smooth page transitions -->
<div id="transition-overlay" class="fixed inset-0 bg-white z-[100] pointer-events-none opacity-0 transition-opacity duration-500"></div>
<script>
// Enhanced client-side functionality with smooth transitions
document.addEventListener('DOMContentLoaded', () => {
const searchContainer = document.getElementById('search-container');
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
const transitionOverlay = document.getElementById('transition-overlay');
// Search functionality
if (searchContainer) {
const searchInput = searchContainer.querySelector('input');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value.toLowerCase().trim();
if (!allPosts) return;
allPosts.forEach((post: HTMLElement) => {
const title = post.querySelector('h3')?.textContent?.toLowerCase() || '';
const description = post.querySelector('.post-excerpt')?.textContent?.toLowerCase() || '';
const tags = Array.from(post.querySelectorAll('.highlighter-tag'))
.map(tag => tag.textContent?.toLowerCase() || '')
.join(' ');
const searchableText = `${title} ${description} ${tags}`;
if (searchableText.includes(query) || query === '') {
post.style.display = 'block';
post.style.opacity = '1';
post.style.transform = 'scale(1)';
} else {
post.style.display = 'none';
post.style.opacity = '0';
post.style.transform = 'scale(0.95)';
}
});
});
}
}
// Global function for tag filtering
(window as any).filterByTag = function(tag: string) {
const postsContainer = document.getElementById('posts-container');
const allPosts = postsContainer?.querySelectorAll('.post-card') as NodeListOf<HTMLElement>;
if (!allPosts) return;
const tagLower = tag.toLowerCase();
allPosts.forEach((post: HTMLElement) => {
const postTags = Array.from(post.querySelectorAll('.highlighter-tag'))
.map(t => t.textContent?.toLowerCase() || '');
if (postTags.includes(tagLower)) {
post.style.display = 'block';
post.style.opacity = '1';
post.style.transform = 'scale(1)';
} else {
post.style.display = 'none';
post.style.opacity = '0';
post.style.transform = 'scale(0.95)';
}
});
// Update search input to show active filter
const searchInput = document.querySelector('#search-container input');
if (searchInput) {
(searchInput as HTMLInputElement).value = `#${tag}`;
}
};
// Smooth transition to blog post with Framer Motion-style animation
const postLinks = document.querySelectorAll('.post-link') as NodeListOf<HTMLAnchorElement>;
postLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const href = link.getAttribute('href');
if (!href || !transitionOverlay) return;
// Get post data for potential future use
const slug = link.getAttribute('data-slug');
const title = link.getAttribute('data-title');
// Add click ripple effect to the card
const card = link.querySelector('.post-card') as HTMLElement;
if (card) {
card.style.transform = 'scale(0.98)';
card.style.transition = 'transform 0.2s ease';
setTimeout(() => {
card.style.transform = 'scale(1)';
}, 150);
}
// Framer Motion-style page transition
// 1. Fade in overlay
transitionOverlay.style.opacity = '1';
transitionOverlay.style.pointerEvents = 'auto';
// 2. Scale down current content
const mainContent = document.querySelector('main');
if (mainContent) {
mainContent.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
mainContent.style.transform = 'scale(0.95)';
mainContent.style.opacity = '0';
}
// 3. Navigate after animation
setTimeout(() => {
window.location.href = href;
}, 400);
});
// Hover 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)';
}
});
});
// Handle incoming transition (when coming back from post)
const urlParams = new URLSearchParams(window.location.search);
const fromPost = urlParams.get('from');
if (fromPost && transitionOverlay) {
// Reverse animation - fade out overlay and scale up content
setTimeout(() => {
transitionOverlay.style.opacity = '0';
transitionOverlay.style.pointerEvents = 'none';
const mainContent = document.querySelector('main');
if (mainContent) {
mainContent.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
mainContent.style.transform = 'scale(1)';
mainContent.style.opacity = '1';
}
}, 50);
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
}
// Stagger animation for posts on load
if (allPosts) {
allPosts.forEach((post, index) => {
post.style.opacity = '0';
post.style.transform = 'translateY(10px)';
post.style.transition = `opacity 0.4s ease-out ${index * 0.05}s, transform 0.4s ease-out ${index * 0.05}s`;
setTimeout(() => {
post.style.opacity = '1';
post.style.transform = 'translateY(0)';
}, 100);
});
}
});
</script>
<style>
/* Post link wrapper for smooth transitions */
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-link .post-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.post-link:hover .post-card {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
}
.post-link:active .post-card {
transform: translateY(0) scale(0.98);
}
/* Transition overlay */
#transition-overlay {
backdrop-filter: blur(10px);
}
/* Main content transition container */
main {
transform-origin: center top;
}
/* Smooth animations for all elements */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
/* Focus styles for all interactive elements */
a:focus,
button:focus,
.post-link:focus,
.highlighter-tag:focus,
input:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
}
/* Remove default outline in favor of custom styles */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
a:focus,
button:focus,
input:focus {
outline: 3px solid #000;
outline-offset: 2px;
}
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out both;
}
/* Enhanced hover states for tags */
.tag-cloud a {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.tag-cloud a:hover {
transform: translateY(-1px) rotate(-1deg);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Selection color */
::selection {
background: #bfdbfe;
color: #1e40af;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</BaseLayout>

View File

@@ -0,0 +1,42 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { blogPosts } from '../../data/blogPosts';
import MediumCard from '../../components/MediumCard.astro';
export async function getStaticPaths() {
const allTags = [...new Set(blogPosts.flatMap(post => post.tags))];
return allTags.map(tag => ({
params: { tag },
props: { tag }
}));
}
const { tag } = Astro.props;
const posts = blogPosts.filter(post => post.tags.includes(tag));
---
<BaseLayout title={`Posts tagged "${tag}"`} description={`All posts tagged with ${tag}`}>
<div class="max-w-3xl mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-slate-900 mb-2">
Posts tagged <span class="highlighter-yellow">{tag}</span>
</h1>
<p class="text-slate-600">
{posts.length} post{posts.length === 1 ? '' : 's'}
</p>
</header>
<div class="space-y-4">
{posts.map(post => (
<MediumCard post={post} />
))}
</div>
<div class="mt-8 pt-6 border-t border-slate-200">
<a href="/" class="text-blue-600 hover:text-blue-800 inline-flex items-center">
← Back to home
</a>
</div>
</div>
</BaseLayout>

579
src/styles/global.css Normal file
View File

@@ -0,0 +1,579 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Medium-inspired clean reading experience */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-slate-800 font-serif antialiased;
font-family: 'Georgia', 'Times New Roman', serif;
line-height: 1.75;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
@apply font-sans font-bold text-slate-900;
}
h1 {
@apply text-3xl md:text-4xl leading-tight mb-8;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h2 {
@apply text-2xl md:text-3xl leading-tight mb-6 mt-12;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h3 {
@apply text-xl md:text-2xl leading-tight mb-4 mt-8;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
h4 {
@apply text-lg md:text-xl leading-tight mb-3 mt-6;
font-family: 'Inter', sans-serif;
letter-spacing: -0.025em;
}
p {
@apply mb-6 text-base leading-relaxed text-slate-700;
}
.lead {
@apply text-xl md:text-2xl text-slate-600 mb-10 leading-relaxed;
font-weight: 400;
}
a {
@apply text-blue-600 hover:text-blue-800 transition-colors;
}
ul, ol {
@apply ml-6 mb-6;
}
li {
@apply mb-2;
}
code {
@apply bg-slate-100 px-1.5 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;
}
/* 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 */
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 */
@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;
}
.post-card h3 {
@apply text-xl font-semibold mb-3 hover:text-blue-600 transition-colors cursor-pointer;
font-weight: 600;
}
.post-meta {
@apply text-sm text-slate-500 font-sans mb-4;
}
.post-excerpt {
@apply text-slate-700 mb-5 leading-relaxed;
}
.post-tags {
@apply flex flex-wrap gap-2;
}
.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;
}
.article-title {
@apply text-4xl md:text-5xl font-bold mb-4;
}
.article-meta {
@apply text-sm text-slate-500 font-sans mb-6;
}
.article-content {
@apply text-lg leading-relaxed;
}
.article-content p {
@apply mb-7;
}
.article-content h2 {
@apply text-2xl font-bold mt-10 mb-4;
}
.article-content h3 {
@apply text-xl font-bold mt-8 mb-3;
}
.article-content ul,
.article-content ol {
@apply ml-6 mb-7;
}
.article-content li {
@apply mb-2;
}
.article-content blockquote {
@apply border-l-4 border-slate-400 pl-6 italic text-slate-600 my-8 text-xl;
}
/* 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 */
.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;
}
/* 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);
}
.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%);
}
.highlighter-yellow:hover {
transform: rotate(-2deg) scale(1.1);
box-shadow: 3px 3px 0 rgba(0,0,0,0.15);
}
.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%);
}
.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;
}
.empty-state svg {
@apply mx-auto mb-4 text-slate-300;
}
/* Line clamp utility for text truncation */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
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 {
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;
}
.link-enhanced:hover::after {
width: 100%;
}
}

68
tailwind.config.js Normal file
View File

@@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
slate: {
850: '#1e293b',
900: '#0f172a',
950: '#020617',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.slate.800'),
a: {
color: theme('colors.primary.600'),
'&:hover': {
color: theme('colors.primary.700'),
},
},
},
},
}),
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.6s ease-out',
'slide-down': 'slideDown 0.6s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}