
- Implemented complete livestream functionality with HLS video player and real-time chat - Added NIP-17 compliant private messaging with kind 14 messages and NIP-44 encryption - Enhanced markdown rendering for blog posts with syntax highlighting and improved formatting - Added NIP-05 identity verification configuration for patrickulrich.com - Reorganized homepage layout with recent activity above projects section - Created comprehensive media sections for blog posts, photos, and videos - Improved UI components with proper TypeScript types and error handling - Added relay preferences discovery for optimized message delivery - Enhanced authentication flow with login modal integration - Updated styling and layout for better user experience 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { useSeoMeta } from '@unhead/react';
|
|
import { MainLayout } from '@/components/layout/MainLayout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { RelaySelector } from '@/components/RelaySelector';
|
|
import { useBlogPosts, type BlogPost } from '@/hooks/useBlogPosts';
|
|
import { BookOpen, Calendar, Clock, ArrowRight, FileText } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
|
|
const Blog = () => {
|
|
useSeoMeta({
|
|
title: 'Blog - Patrick Ulrich',
|
|
description: 'Read Patrick\'s thoughts on Bitcoin, Nostr, and digital sovereignty.',
|
|
});
|
|
|
|
const { data: blogPosts, isLoading, error } = useBlogPosts();
|
|
|
|
const formatDate = (timestamp: number) => {
|
|
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const estimateReadTime = (content: string) => {
|
|
// Rough estimate: 200 words per minute
|
|
const words = content.trim().split(/\s+/).length;
|
|
const minutes = Math.ceil(words / 200);
|
|
return `${minutes} min read`;
|
|
};
|
|
|
|
const getExcerpt = (content: string, maxLength = 200) => {
|
|
// Remove markdown formatting for a cleaner excerpt
|
|
const cleaned = content
|
|
.replace(/#{1,6}\s/g, '') // Remove headers
|
|
.replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold
|
|
.replace(/\*(.*?)\*/g, '$1') // Remove italic
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text
|
|
.replace(/`([^`]+)`/g, '$1') // Remove inline code
|
|
.trim();
|
|
|
|
if (cleaned.length <= maxLength) return cleaned;
|
|
|
|
// Find the last complete word within the limit
|
|
const truncated = cleaned.substring(0, maxLength);
|
|
const lastSpace = truncated.lastIndexOf(' ');
|
|
return (lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated) + '...';
|
|
};
|
|
|
|
const getArticleUrl = (post: BlogPost) => {
|
|
const naddr = nip19.naddrEncode({
|
|
identifier: post.dTag,
|
|
pubkey: post.pubkey,
|
|
kind: 30023,
|
|
});
|
|
return `/blog/${naddr}`;
|
|
};
|
|
|
|
// Get featured post (most recent or first with image)
|
|
const featuredPost = blogPosts?.find(post => post.image) || blogPosts?.[0];
|
|
const otherPosts = blogPosts?.filter(post => post.id !== featuredPost?.id) || [];
|
|
|
|
return (
|
|
<MainLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-4xl font-bold mb-4 flex items-center gap-3">
|
|
<BookOpen className="h-10 w-10 text-primary" />
|
|
Blog
|
|
</h1>
|
|
<p className="text-lg text-muted-foreground">
|
|
Long-form thoughts on Bitcoin, Nostr, and digital sovereignty. All articles are published on Nostr using NIP-23.
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-8">
|
|
{/* Featured Post Skeleton */}
|
|
<section className="mb-12">
|
|
<Card className="overflow-hidden">
|
|
<div className="grid md:grid-cols-2">
|
|
<Skeleton className="aspect-video md:aspect-auto" />
|
|
<CardContent className="p-6 md:p-8 space-y-4">
|
|
<Skeleton className="h-6 w-20" />
|
|
<Skeleton className="h-8 w-full" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<div className="flex gap-4">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
<Skeleton className="h-10 w-32" />
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* Other Posts Skeleton */}
|
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-4 w-full mb-2" />
|
|
<Skeleton className="h-4 w-4/5 mb-4" />
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-6 w-16" />
|
|
<Skeleton className="h-6 w-20" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-12">
|
|
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
<p className="text-muted-foreground mb-4">Failed to load blog posts</p>
|
|
<RelaySelector />
|
|
</div>
|
|
) : !blogPosts?.length ? (
|
|
<div className="col-span-full">
|
|
<Card className="border-dashed">
|
|
<CardContent className="py-12 px-8 text-center">
|
|
<div className="max-w-sm mx-auto space-y-6">
|
|
<FileText className="h-12 w-12 text-muted-foreground mx-auto" />
|
|
<p className="text-muted-foreground">
|
|
No blog posts found. Try another relay?
|
|
</p>
|
|
<RelaySelector className="w-full" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-12">
|
|
{/* Featured Post */}
|
|
{featuredPost && (
|
|
<section className="mb-12">
|
|
<h2 className="text-2xl font-bold mb-6">Featured Article</h2>
|
|
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
|
<div className="grid md:grid-cols-2">
|
|
{featuredPost.image && (
|
|
<div className="aspect-video md:aspect-auto">
|
|
<img
|
|
src={featuredPost.image}
|
|
alt={featuredPost.title || 'Featured article'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<CardContent className={`p-6 md:p-8 space-y-4 ${!featuredPost.image ? 'md:col-span-2' : ''}`}>
|
|
<Badge variant="secondary">Featured</Badge>
|
|
<CardTitle className="text-2xl md:text-3xl">
|
|
{featuredPost.title || `Untitled Post #${featuredPost.dTag}`}
|
|
</CardTitle>
|
|
<p className="text-muted-foreground text-lg">
|
|
{featuredPost.summary || getExcerpt(featuredPost.content, 300)}
|
|
</p>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-4 w-4" />
|
|
{formatDate(featuredPost.publishedAt || featuredPost.createdAt)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-4 w-4" />
|
|
{estimateReadTime(featuredPost.content)}
|
|
</span>
|
|
</div>
|
|
{featuredPost.hashtags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{featuredPost.hashtags.slice(0, 3).map((tag) => (
|
|
<Badge key={tag} variant="outline">
|
|
#{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Button asChild className="gap-2">
|
|
<Link to={getArticleUrl(featuredPost)}>
|
|
Read Article
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
)}
|
|
|
|
{/* Other Posts Grid */}
|
|
{otherPosts.length > 0 && (
|
|
<section>
|
|
<h2 className="text-2xl font-bold mb-6">Recent Articles</h2>
|
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{otherPosts.map((post) => (
|
|
<Card key={post.id} className="hover:shadow-lg transition-shadow">
|
|
<CardHeader>
|
|
<CardTitle className="line-clamp-2">
|
|
{post.title || `Untitled Post #${post.dTag}`}
|
|
</CardTitle>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-3 w-3" />
|
|
{formatDate(post.publishedAt || post.createdAt)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{estimateReadTime(post.content)}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-muted-foreground line-clamp-3">
|
|
{post.summary || getExcerpt(post.content)}
|
|
</p>
|
|
{post.hashtags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{post.hashtags.slice(0, 2).map((tag) => (
|
|
<Badge key={tag} variant="outline" className="text-xs">
|
|
#{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Button asChild variant="outline" size="sm" className="w-full gap-2">
|
|
<Link to={getArticleUrl(post)}>
|
|
Read More
|
|
<ArrowRight className="h-3 w-3" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default Blog; |