saulteafarmer 241e2f7a7b
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Comprehensive site update with livestream functionality and Nostr improvements
- 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>
2025-08-27 13:10:56 -04:00

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;