wip
This commit is contained in:
@@ -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
1797
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
576
scripts/smoke-test.ts
Normal file
576
scripts/smoke-test.ts
Normal 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
178
scripts/test-links.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
36
src/components/Footer.tsx
Normal 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
54
src/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
src/components/MediumCard.astro
Normal file
130
src/components/MediumCard.astro
Normal 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>
|
||||
141
src/components/SearchBar.tsx
Normal file
141
src/components/SearchBar.tsx
Normal 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
125
src/components/Tag.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
716
src/pages/blog/[slug].astro
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
42
src/pages/tags/[tag].astro
Normal file
42
src/pages/tags/[tag].astro
Normal 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
579
src/styles/global.css
Normal 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
68
tailwind.config.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user