-
-
- Welcome to Your Blank App
-
-
- Start building your amazing project here!
-
+
+
+ {/* Livestream or Hero Section */}
+ {shouldShowLiveStream ? (
+
+
+
+
+ Live Now
+
+
+ Join Patrick's live stream
+
+
+
+
+ {/* Toolbar below stream and chat */}
+
+
+
+ ) : (
+ /* Hero Section */
+
+
+
+
+
+ Patrick Ulrich
+
+
+
+ Building tools to empower digital sovereignty and individual freedom
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Recent Activity Feed */}
+
+
+
+
Recent Activity
+
+ Latest posts, photos, and videos from the decentralized web
+
+
+
+ {activityLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+ ))}
+
+ ) : recentActivity && recentActivity.length > 0 ? (
+
+ {recentActivity.map((item) => {
+ const Icon = getActivityIcon(item.type);
+
+ return (
+
+
+
+ {/* Activity Icon & Media Preview */}
+
+ {item.mediaUrl ? (
+
+

+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Content */}
+
+
+
+ {getActivityTypeLabel(item.type)}
+
+
•
+
+
+ {formatDate(item.timestamp)}
+
+
+
+
+
+
+ {item.title && (
+
+ {item.title}
+
+ )}
+
+ {item.content && (
+
+ {item.type === 'post' ? (
+
+ ) : (
+
{truncateContent(item.content)}
+ )}
+
+ )}
+
+ {item.hashtags.length > 0 && (
+
+ {item.hashtags.slice(0, 3).map((tag) => (
+
+ #{tag}
+
+ ))}
+ {item.hashtags.length > 3 && (
+
+ +{item.hashtags.length - 3}
+
+ )}
+
+ )}
+
+ {/* Action Buttons */}
+
+ {item.type === 'photo' && (
+
+
+ View in Gallery
+
+ )}
+
+ {item.type === 'video' && (
+
+
+ Watch Video
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+
+ No recent activity found. Check back soon for new posts, photos, and videos!
+
+
+
+ )}
+
+
+
+ {/* Projects Section */}
+
+
+
+
+
+
+
-
+
);
};
diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx
index 5aa4439..89f2c39 100644
--- a/src/pages/NIP19Page.tsx
+++ b/src/pages/NIP19Page.tsx
@@ -1,6 +1,7 @@
import { nip19 } from 'nostr-tools';
import { useParams } from 'react-router-dom';
import NotFound from './NotFound';
+import BlogPost from './BlogPost';
export function NIP19Page() {
const { nip19: identifier } = useParams<{ nip19: string }>();
@@ -33,8 +34,8 @@ export function NIP19Page() {
return
Event placeholder
;
case 'naddr':
- // AI agent should implement addressable event view here
- return
Addressable event placeholder
;
+ // Handle blog articles and other addressable events
+ return
;
default:
return
;
diff --git a/src/pages/Photos.tsx b/src/pages/Photos.tsx
new file mode 100644
index 0000000..4225f13
--- /dev/null
+++ b/src/pages/Photos.tsx
@@ -0,0 +1,241 @@
+import { useSeoMeta } from '@unhead/react';
+import { MainLayout } from '@/components/layout/MainLayout';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { RelaySelector } from '@/components/RelaySelector';
+import { usePhotos, type PhotoEvent } from '@/hooks/usePhotos';
+import { Image as ImageIcon, Calendar, MapPin, Hash } from 'lucide-react';
+import { useState } from 'react';
+
+const Photos = () => {
+ useSeoMeta({
+ title: 'Photos - Patrick Ulrich',
+ description: 'Photo gallery from Patrick\'s Nostr posts.',
+ });
+
+ const { data: photos, isLoading, error } = usePhotos();
+ const [selectedPhoto, setSelectedPhoto] = useState
(null);
+
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ };
+
+ const cleanContent = (content: string) => {
+ // For NIP-68 kind 20 events, content is the description, no need to clean URLs
+ return content.trim();
+ };
+
+ return (
+
+
+
+
+
+ Photos
+
+
+ Photo gallery featuring picture-first posts. Images shared using NIP-68 on the decentralized network.
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+
+
Failed to load photos
+
+
+ ) : !photos?.length ? (
+
+
+
+
+
+
+ No photos found. Try another relay?
+
+
+
+
+
+
+ ) : (
+
+ {/* Photo Grid */}
+
+ {photos.map((photo) => (
+
setSelectedPhoto(photo)}
+ >
+
+ {photo.imageUrls[0] ? (
+

+ ) : (
+
+
+
+ )}
+ {photo.imageUrls.length > 1 && (
+
+ +{photo.imageUrls.length - 1}
+
+ )}
+
+
+
+
+ {cleanContent(photo.content) && (
+
+ {cleanContent(photo.content)}
+
+ )}
+
+
+
+ {formatDate(photo.createdAt)}
+ {photo.location && (
+ <>
+
+ {photo.location}
+ >
+ )}
+
+
+ {photo.hashtags.length > 0 && (
+
+ {photo.hashtags.slice(0, 2).map((tag) => (
+
+
+ {tag}
+
+ ))}
+ {photo.hashtags.length > 2 && (
+
+ +{photo.hashtags.length - 2}
+
+ )}
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Photo Modal */}
+ {selectedPhoto && (
+
setSelectedPhoto(null)}
+ >
+
e.stopPropagation()}
+ >
+
+ {/* Image */}
+
+ {selectedPhoto.imageUrls.length === 1 ? (
+

+ ) : (
+
+
+ {selectedPhoto.imageUrls.map((url, index) => (
+

+ ))}
+
+
+ )}
+
+
+ {/* Details */}
+
+
+ {cleanContent(selectedPhoto.content) && (
+
+
Description
+
+ {cleanContent(selectedPhoto.content)}
+
+
+ )}
+
+
+
Details
+
+
+
+ {formatDate(selectedPhoto.createdAt)}
+
+ {selectedPhoto.location && (
+
+
+ {selectedPhoto.location}
+
+ )}
+
+
+ {selectedPhoto.imageUrls.length} image{selectedPhoto.imageUrls.length !== 1 ? 's' : ''}
+
+
+
+
+ {selectedPhoto.hashtags.length > 0 && (
+
+
Tags
+
+ {selectedPhoto.hashtags.map((tag) => (
+
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default Photos;
\ No newline at end of file
diff --git a/src/pages/Videos.tsx b/src/pages/Videos.tsx
new file mode 100644
index 0000000..37b9270
--- /dev/null
+++ b/src/pages/Videos.tsx
@@ -0,0 +1,390 @@
+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 { useVideos, type VideoEvent } from '@/hooks/useVideos';
+import { Video, Play, Clock, Calendar, Users, Hash, AlertTriangle, X } from 'lucide-react';
+import { useState } from 'react';
+
+const Videos = () => {
+ useSeoMeta({
+ title: 'Videos - Patrick Ulrich',
+ description: 'Watch Patrick\'s videos on Bitcoin, Nostr, and digital sovereignty.',
+ });
+
+ const { data: videos, isLoading, error } = useVideos();
+ const [selectedVideo, setSelectedVideo] = useState(null);
+
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ };
+
+ const formatDuration = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
+ }
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
+ };
+
+ const getBestThumbnail = (video: VideoEvent): string | undefined => {
+ return video.thumbnailUrls[0];
+ };
+
+ const getBestVideoUrl = (video: VideoEvent): string | undefined => {
+ return video.videoUrls[0]?.url;
+ };
+
+
+
+ const normalVideos = videos?.filter(v => v.kind === 21) || [];
+ const shortVideos = videos?.filter(v => v.kind === 22) || [];
+
+ return (
+
+
+
+
+
+ Videos
+
+
+ Video content on Bitcoin, Nostr, and digital sovereignty. All videos are published on Nostr using NIP-71.
+
+
+
+ {isLoading ? (
+
+ {/* Loading Skeleton */}
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ ) : error ? (
+
+
+
Failed to load videos
+
+
+ ) : !videos?.length ? (
+
+
+
+
+
+
+ No videos found. Try another relay?
+
+
+
+
+
+
+ ) : (
+
+ {/* Normal Videos */}
+ {normalVideos.length > 0 && (
+
+ Videos
+
+ {normalVideos.map((video) => (
+
+
+ {getBestThumbnail(video) ? (
+
})
+ ) : (
+
+
+
+ )}
+
setSelectedVideo(video)}
+ >
+
+
+ {video.duration && (
+
+ {formatDuration(video.duration)}
+
+ )}
+ {video.contentWarning && (
+
+ )}
+
+
+
+
+ {video.title || 'Untitled Video'}
+
+
+
+
+ {formatDate(video.publishedAt || video.createdAt)}
+
+ {video.duration && (
+
+
+ {formatDuration(video.duration)}
+
+ )}
+
+
+
+
+ {video.content && (
+
+ {video.content}
+
+ )}
+
+ {video.participants.length > 0 && (
+
+
+ {video.participants.length} participant{video.participants.length !== 1 ? 's' : ''}
+
+ )}
+
+ {video.hashtags.length > 0 && (
+
+ {video.hashtags.slice(0, 3).map((tag) => (
+
+
+ {tag}
+
+ ))}
+ {video.hashtags.length > 3 && (
+
+ +{video.hashtags.length - 3} more
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Short Videos */}
+ {shortVideos.length > 0 && (
+
+ Shorts
+
+ {shortVideos.map((video) => (
+
+
+ {getBestThumbnail(video) ? (
+
})
+ ) : (
+
+
+
+ )}
+
setSelectedVideo(video)}
+ >
+
+
+ {video.duration && (
+
+ {formatDuration(video.duration)}
+
+ )}
+ {video.contentWarning && (
+
+ )}
+
+
+
+
+ {video.title || 'Untitled Short'}
+
+ {video.hashtags.length > 0 && (
+
+ {video.hashtags.slice(0, 2).map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Video Player Modal */}
+ {selectedVideo && (
+
setSelectedVideo(null)}
+ >
+
e.stopPropagation()}
+ >
+ {/* Close Button */}
+
+
+ {/* Video Player */}
+
+ {getBestVideoUrl(selectedVideo) ? (
+
+ ) : (
+
+
+
+
Video not available
+
+
+ )}
+
+
+ {/* Video Info */}
+
+
+
+
+ {selectedVideo.title || 'Untitled Video'}
+
+
+
+
+ {formatDate(selectedVideo.publishedAt || selectedVideo.createdAt)}
+
+ {selectedVideo.duration && (
+
+
+ {formatDuration(selectedVideo.duration)}
+
+ )}
+
+
+
+ {selectedVideo.content && (
+
+
Description
+
+ {selectedVideo.content}
+
+
+ )}
+
+ {selectedVideo.hashtags.length > 0 && (
+
+
Tags
+
+ {selectedVideo.hashtags.map((tag) => (
+
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+ {selectedVideo.segments.length > 0 && (
+
+
Chapters
+
+ {selectedVideo.segments.map((segment, index) => (
+
+
+ {segment.start} - {segment.end}
+
+ {segment.title}
+
+ ))}
+
+
+ )}
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default Videos;
\ No newline at end of file
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 8ff2536..8816995 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -19,6 +19,9 @@ export default {
}
},
extend: {
+ fontFamily: {
+ sans: ['Outfit Variable', 'Outfit', 'system-ui', 'sans-serif'],
+ },
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',