Comprehensive site update with livestream functionality and Nostr improvements
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled

- 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>
This commit is contained in:
saulteafarmer 2025-08-27 13:10:56 -04:00
parent df80653a83
commit 241e2f7a7b
31 changed files with 5566 additions and 52 deletions

View File

@ -3,6 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Patrick Ulrich - Bitcoin & Digital Sovereignty</title>
<meta name="description" content="COO at Lexington Bitcoin Consulting. Building on Nostr, empowering digital sovereignty, and connecting Bitcoiners in Kentucky since 2015.">
<meta property="og:type" content="website">
<meta property="og:title" content="Patrick Ulrich - Bitcoin & Digital Sovereignty">
<meta property="og:description" content="COO at Lexington Bitcoin Consulting. Building on Nostr, empowering digital sovereignty, and connecting Bitcoiners in Kentucky since 2015.">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<link rel="manifest" href="/manifest.webmanifest">
</head>

1568
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/outfit": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
@ -50,6 +51,8 @@
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.11",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"nostr-tools": "^2.13.0",
@ -58,9 +61,12 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",

View File

@ -0,0 +1,13 @@
{
"names": {
"_": "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d",
"oldaccount": "ad2e457bd523f56878091176bca09e3a824fd82c04719fa7caf7ddecb28e32e7"
},
"relays": {
"0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d": [
"wss://nostr.wine",
"wss://relay.goodmorningbitcoin.com",
"wss://relay.damus.io"
]
}
}

View File

@ -0,0 +1,24 @@
{
"name": "Patrick Ulrich",
"short_name": "Patrick",
"description": "COO at Lexington Bitcoin Consulting. Building on Nostr, empowering digital sovereignty.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffa500",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@ -31,7 +31,7 @@ const queryClient = new QueryClient({
});
const defaultConfig: AppConfig = {
theme: "light",
theme: "dark",
relayUrl: "wss://relay.primal.net",
};

View File

@ -2,6 +2,10 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
import Index from "./pages/Index";
import Blog from "./pages/Blog";
import BlogPost from "./pages/BlogPost";
import Videos from "./pages/Videos";
import Photos from "./pages/Photos";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
@ -11,6 +15,10 @@ export function AppRouter() {
<ScrollToTop />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:naddr" element={<BlogPost />} />
<Route path="/videos" element={<Videos />} />
<Route path="/photos" element={<Photos />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View File

@ -0,0 +1,186 @@
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
interface MarkdownContentProps {
content: string;
className?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
// Process content to handle Nostr references before markdown parsing
const processedContent = useMemo(() => {
let processed = content;
// Find and replace Nostr references (npub, naddr, note, nevent, nprofile)
const nostrRegex = /nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)/g;
processed = processed.replace(nostrRegex, (match, prefix, data) => {
const nostrId = `${prefix}${data}`;
try {
const decoded = nip19.decode(nostrId);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
return `[${match}](/${nostrId})`;
} else if (decoded.type === 'note' || decoded.type === 'nevent' || decoded.type === 'naddr') {
return `[${match}](/${nostrId})`;
}
return match;
} catch {
return match;
}
});
return processed;
}, [content]);
return (
<div className={cn('prose prose-lg dark:prose-invert max-w-none', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
// Custom link renderer to handle internal links
a: ({ node, href, children, ...props }) => {
// Check if it's an internal Nostr link
if (href?.startsWith('/')) {
return (
<Link
to={href}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 underline"
>
{children}
</Link>
);
}
// External links
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 underline"
{...props}
>
{children}
</a>
);
},
// Custom code block styling
pre: ({ node, children, ...props }) => (
<pre
className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto border"
{...props}
>
{children}
</pre>
),
// Inline code styling - react-markdown handles block vs inline automatically
code: ({ node, children, ...props }) => (
<code
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono border"
{...props}
>
{children}
</code>
),
// Custom blockquote styling
blockquote: ({ node, children, ...props }) => (
<blockquote
className="border-l-4 border-blue-500 pl-4 italic bg-blue-50 dark:bg-blue-950/30 py-2 rounded-r"
{...props}
>
{children}
</blockquote>
),
// Custom table styling
table: ({ node, children, ...props }) => (
<div className="overflow-x-auto">
<table
className="w-full border-collapse border border-gray-300 dark:border-gray-600 rounded-lg"
{...props}
>
{children}
</table>
</div>
),
th: ({ node, children, ...props }) => (
<th
className="border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 px-4 py-2 text-left font-semibold"
{...props}
>
{children}
</th>
),
td: ({ node, children, ...props }) => (
<td
className="border border-gray-300 dark:border-gray-600 px-4 py-2"
{...props}
>
{children}
</td>
),
// Custom heading styling with better spacing
h1: ({ node, children, ...props }) => (
<h1 className="text-3xl font-bold mt-8 mb-4 first:mt-0" {...props}>
{children}
</h1>
),
h2: ({ node, children, ...props }) => (
<h2 className="text-2xl font-bold mt-8 mb-4 first:mt-0" {...props}>
{children}
</h2>
),
h3: ({ node, children, ...props }) => (
<h3 className="text-xl font-bold mt-6 mb-3 first:mt-0" {...props}>
{children}
</h3>
),
// Custom list styling
ul: ({ node, children, ...props }) => (
<ul className="list-disc list-inside space-y-2 my-4" {...props}>
{children}
</ul>
),
ol: ({ node, children, ...props }) => (
<ol className="list-decimal list-inside space-y-2 my-4" {...props}>
{children}
</ol>
),
li: ({ node, children, ...props }) => (
<li className="leading-relaxed" {...props}>
{children}
</li>
),
// Paragraph spacing
p: ({ node, children, ...props }) => (
<p className="leading-relaxed my-4 first:mt-0 last:mb-0" {...props}>
{children}
</p>
),
}}
>
{processedContent}
</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,232 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useRelayPreferences } from '@/hooks/useRelayPreferences';
import { MessageSquare, Send, Loader2 } from 'lucide-react';
import { getEventHash, generateSecretKey, nip44, finalizeEvent } from 'nostr-tools';
import { NRelay1, type NostrEvent } from '@nostrify/nostrify';
import LoginDialog from '@/components/auth/LoginDialog';
interface MessageDialogProps {
recipientPubkey: string;
children?: React.ReactNode;
}
const TWO_DAYS = 2 * 24 * 60 * 60;
const now = () => Math.round(Date.now() / 1000);
const randomNow = () => Math.round(now() - (Math.random() * TWO_DAYS));
export function MessageDialog({ recipientPubkey, children }: MessageDialogProps) {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const { data: recipientRelayPrefs } = useRelayPreferences(recipientPubkey);
const { data: senderRelayPrefs } = useRelayPreferences(user?.pubkey || '');
// Function to publish event to specific relays
const publishToRelays = async (event: NostrEvent, relayUrls: string[]) => {
if (!relayUrls.length) {
// Fallback to default publishing
await publishEvent(event);
return;
}
const publishPromises = relayUrls.map(async (url) => {
try {
const relay = new NRelay1(url);
await relay.event(event, { signal: AbortSignal.timeout(5000) });
console.log(`Event published to ${url}`);
} catch (error) {
console.warn(`Failed to publish to ${url}:`, error);
}
});
// Wait for at least one successful publish
await Promise.allSettled(publishPromises);
};
const handleSend = async () => {
if (!user?.signer || !message.trim()) return;
if (!user.signer.nip44) {
toast({
title: "Encryption not supported",
description: "Your Nostr client doesn't support NIP-44 encryption. Please upgrade to send private messages.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
// Create rumor (unsigned event) - NIP-17 Direct Message
const rumor: Record<string, unknown> = {
kind: 14,
content: message.trim(),
created_at: now(),
tags: [['p', recipientPubkey]],
pubkey: user.pubkey,
};
rumor.id = getEventHash(rumor as Parameters<typeof getEventHash>[0]);
// Create seal (kind 13) - encrypt rumor to recipient
const sealContent = await user.signer.nip44.encrypt(recipientPubkey, JSON.stringify(rumor));
const seal = {
kind: 13,
content: sealContent,
created_at: randomNow(),
tags: [],
pubkey: user.pubkey,
};
const signedSeal = await user.signer.signEvent(seal as Parameters<typeof user.signer.signEvent>[0]);
// Create gift wrap (kind 1059) with ephemeral key - NIP-17 compliant
const ephemeralSecretKey = generateSecretKey();
// Encrypt the seal with ephemeral key to recipient using NIP-44
const giftWrapContent = nip44.encrypt(JSON.stringify(signedSeal), nip44.getConversationKey(ephemeralSecretKey, recipientPubkey));
const giftWrap = {
kind: 1059,
content: giftWrapContent,
created_at: randomNow(),
tags: [['p', recipientPubkey]],
};
// Finalize the event with ephemeral key (adds id, pubkey, and sig)
const signedGiftWrap = finalizeEvent(giftWrap, ephemeralSecretKey);
// Create a second gift wrap for the sender (NIP-17 requirement)
const senderEphemeralSecretKey = generateSecretKey();
const senderGiftWrapContent = nip44.encrypt(JSON.stringify(signedSeal), nip44.getConversationKey(senderEphemeralSecretKey, user.pubkey));
const senderGiftWrap = {
kind: 1059,
content: senderGiftWrapContent,
created_at: randomNow(),
tags: [['p', user.pubkey]], // Tag sender for their own copy
};
const signedSenderGiftWrap = finalizeEvent(senderGiftWrap, senderEphemeralSecretKey);
// Publish to recipient's preferred DM relays (NIP-17 compliant)
const recipientRelays = recipientRelayPrefs?.relays || [];
const senderRelays = senderRelayPrefs?.relays || [];
if (recipientRelays.length > 0) {
console.log(`Publishing DM to recipient's preferred relays:`, recipientRelays);
await publishToRelays(signedGiftWrap, recipientRelays);
} else {
console.log('No recipient relay preferences found, using default relay');
await publishEvent(signedGiftWrap);
}
// Also send sender's copy to their preferred relays
if (senderRelays.length > 0) {
console.log(`Publishing sender copy to their preferred relays:`, senderRelays);
await publishToRelays(signedSenderGiftWrap, senderRelays);
} else {
console.log('No sender relay preferences found, using default relay for sender copy');
await publishEvent(signedSenderGiftWrap);
}
toast({
title: "Message sent",
description: "Your private message has been sent successfully.",
});
setMessage('');
setOpen(false);
} catch (error) {
console.error('Failed to send message:', error);
toast({
title: "Failed to send message",
description: "There was an error sending your message. Please try again.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
if (!user) {
return (
<>
<Button onClick={() => setShowLoginDialog(true)} className="gap-2">
<MessageSquare className="w-4 h-4" />
Login Required
</Button>
<LoginDialog
isOpen={showLoginDialog}
onClose={() => setShowLoginDialog(false)}
onLogin={() => {
setShowLoginDialog(false);
toast({
title: "Login successful",
description: "You can now send private messages.",
});
}}
/>
</>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button className="gap-2">
<MessageSquare className="w-4 h-4" />
Send Private Message
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send Private Message</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
placeholder="Type your message here..."
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={4}
disabled={isLoading}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
This message will be encrypted using NIP-17 with NIP-44 encryption.
</p>
<Button
onClick={handleSend}
disabled={!message.trim() || isLoading}
className="gap-2"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -5,6 +5,10 @@ import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Clock, MessageCircle, Image as ImageIcon, Video, Play } from 'lucide-react';
interface NoteContentProps {
event: NostrEvent;
@ -61,6 +65,12 @@ export function NoteContent({
parts.push(
<NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
} else if (decoded.type === 'nevent' || decoded.type === 'note') {
// Render referenced events inline
const eventId = decoded.type === 'nevent' ? decoded.data.id : decoded.data;
parts.push(
<ReferencedEvent key={`event-${keyCounter++}`} eventId={eventId} />
);
} else {
// For other types, just show as a link
parts.push(
@ -107,9 +117,12 @@ export function NoteContent({
return parts;
}, [event]);
const media = extractMediaFromEvent(event);
return (
<div className={cn("whitespace-pre-wrap break-words", className)}>
{content.length > 0 ? content : event.content}
<MediaGallery media={media} />
</div>
);
}
@ -134,4 +147,354 @@ function NostrMention({ pubkey }: { pubkey: string }) {
@{displayName}
</Link>
);
}
// Helper function to extract media from different event types
function extractMediaFromEvent(event: NostrEvent) {
const media: Array<{ type: 'image' | 'video'; url: string; thumbnail?: string; alt?: string }> = [];
if (event.kind === 20) {
// NIP-68 Picture events
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
let url = '';
let alt = '';
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ') && /\.(jpg|jpeg|png|gif|webp|apng|avif)/i.test(item)) {
url = item.substring(4);
} else if (item.startsWith('alt ')) {
alt = item.substring(4);
}
}
if (url) {
media.push({ type: 'image', url, alt });
}
}
} else if (event.kind === 21 || event.kind === 22) {
// NIP-71 Video events
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
let url = '';
let thumbnail = '';
let alt = '';
let isVideo = false;
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ')) {
url = item.substring(4);
} else if (item.startsWith('m ') && item.includes('video/')) {
isVideo = true;
} else if (item.startsWith('image ')) {
thumbnail = item.substring(6);
} else if (item.startsWith('alt ')) {
alt = item.substring(4);
}
}
if (url && isVideo) {
media.push({ type: 'video', url, thumbnail, alt });
}
}
} else if (event.kind === 1) {
// Kind 1 posts with image URLs in content or imeta tags
// Extract URLs from content
const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|apng|avif)/gi;
const imageUrls = event.content.match(urlRegex) || [];
imageUrls.forEach(url => {
media.push({ type: 'image', url });
});
// Also check imeta tags for kind 1 (some clients use them)
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
let url = '';
let alt = '';
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ') && /\.(jpg|jpeg|png|gif|webp|apng|avif)/i.test(item)) {
url = item.substring(4);
} else if (item.startsWith('alt ')) {
alt = item.substring(4);
}
}
if (url) {
media.push({ type: 'image', url, alt });
}
}
}
return media;
}
// Component to render media gallery
function MediaGallery({ media, compact = false }: {
media: Array<{ type: 'image' | 'video'; url: string; thumbnail?: string; alt?: string }>;
compact?: boolean;
}) {
if (media.length === 0) return null;
const maxItems = compact ? 2 : 4;
const displayMedia = media.slice(0, maxItems);
const hasMore = media.length > maxItems;
return (
<div className={cn("grid gap-2 mt-3",
displayMedia.length === 1 ? "grid-cols-1" :
displayMedia.length === 2 ? "grid-cols-2" :
"grid-cols-2 md:grid-cols-3"
)}>
{displayMedia.map((item, index) => (
<div key={index} className="relative group">
{item.type === 'image' ? (
<div className={cn(
"relative rounded-lg overflow-hidden bg-muted",
compact ? "aspect-video" : "aspect-square"
)}>
<img
src={item.url}
alt={item.alt || 'Image'}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-white opacity-0 group-hover:opacity-70 transition-opacity" />
</div>
</div>
) : (
<div className={cn(
"relative rounded-lg overflow-hidden bg-muted",
compact ? "aspect-video" : "aspect-square"
)}>
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.alt || 'Video thumbnail'}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-black/60 flex items-center justify-center">
<Play className="w-4 h-4 text-white ml-0.5" />
</div>
</div>
</div>
)}
</div>
))}
{hasMore && (
<div className={cn(
"relative rounded-lg overflow-hidden bg-muted/50 flex items-center justify-center border-2 border-dashed border-muted-foreground/30",
compact ? "aspect-video" : "aspect-square"
)}>
<div className="text-center">
<span className="text-lg font-semibold text-muted-foreground">
+{media.length - maxItems}
</span>
<div className="text-xs text-muted-foreground">more</div>
</div>
</div>
)}
</div>
);
}
// Simple component to render content with clickable links (no nostr references to avoid recursion)
function SimpleContent({ content }: { content: string }) {
const parts = useMemo(() => {
const text = content;
// Only parse URLs and hashtags, not nostr references
const regex = /(https?:\/\/[^\s]+)|(#\w+)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let keyCounter = 0;
while ((match = regex.exec(text)) !== null) {
const [fullMatch, url, hashtag] = match;
const index = match.index;
// Add text before this match
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
}
if (url) {
// Handle URLs
parts.push(
<a
key={`url-${keyCounter++}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{url}
</a>
);
} else if (hashtag) {
// Handle hashtags
const tag = hashtag.slice(1); // Remove the #
parts.push(
<Link
key={`hashtag-${keyCounter++}`}
to={`/t/${tag}`}
className="text-blue-500 hover:underline"
>
{hashtag}
</Link>
);
}
lastIndex = index + fullMatch.length;
}
// Add any remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
// If no special content was found, just use the plain text
if (parts.length === 0) {
parts.push(text);
}
return parts;
}, [content]);
return <>{parts}</>;
}
// Component to render referenced events inline
function ReferencedEvent({ eventId }: { eventId: string }) {
const { nostr } = useNostr();
const { data: event, isLoading, error } = useQuery({
queryKey: ['referenced-event', eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query([
{
ids: [eventId],
limit: 1,
}
], { signal });
return events[0] || null;
},
});
const author = useAuthor(event?.pubkey || '');
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
});
};
const truncateContent = (content: string, maxLength = 200) => {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength).trim() + '...';
};
if (isLoading) {
return (
<Card className="my-3 animate-pulse">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-1/4" />
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
</div>
</CardContent>
</Card>
);
}
if (error || !event) {
return (
<Card className="my-3 border-destructive/20">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MessageCircle className="w-4 h-4" />
<span>Referenced post not found</span>
</div>
</CardContent>
</Card>
);
}
const displayName = author.data?.metadata?.name ?? genUserName(event.pubkey);
const noteId = nip19.noteEncode(eventId);
const jumbleUrl = `https://jumble.social/${noteId}`;
return (
<Card className="my-3 bg-muted/30 border-l-4 border-l-primary/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
{author.data?.metadata?.picture ? (
<img
src={author.data.metadata.picture}
alt={displayName}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-primary" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm">{displayName}</span>
<span className="text-xs text-muted-foreground"></span>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDate(event.created_at)}
</div>
<a
href={jumbleUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
title="Open in Jumble.social"
>
<MessageCircle className="w-3 h-3" />
</a>
</div>
<div className="text-sm whitespace-pre-wrap break-words">
<SimpleContent content={truncateContent(event.content)} />
</div>
<MediaGallery media={extractMediaFromEvent(event)} compact={true} />
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,43 @@
import { useState } from "react";
import { Sidebar } from "./Sidebar";
import { Button } from "@/components/ui/button";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { Menu } from "lucide-react";
interface MainLayoutProps {
children: React.ReactNode;
}
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
export function MainLayout({ children }: MainLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user } = useCurrentUser();
const isPatrick = user?.pubkey === PATRICK_PUBKEY;
return (
<div className="min-h-screen bg-background">
{/* Floating hamburger menu */}
<Button
variant="default"
size="icon"
onClick={() => setSidebarOpen(true)}
className="fixed top-4 left-4 z-50 shadow-lg"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</Button>
<Sidebar
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
isPatrick={isPatrick}
/>
<main className="w-full">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { Home, BookOpen, Video, Image } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { LoginArea } from "@/components/auth/LoginArea";
import { cn } from "@/lib/utils";
interface SidebarProps {
open: boolean;
onClose: () => void;
isPatrick?: boolean;
}
const navigation = [
{ name: "Home", href: "/", icon: Home },
{ name: "Blog", href: "/blog", icon: BookOpen },
{ name: "Photos", href: "/photos", icon: Image },
{ name: "Videos", href: "/videos", icon: Video },
];
export function Sidebar({ open, onClose }: SidebarProps) {
const location = useLocation();
const SidebarContent = () => (
<div className="flex h-full flex-col">
<div className="p-6 pb-4 border-b">
<LoginArea className="w-full" />
</div>
<nav className="flex-1 p-6">
<ul className="space-y-2 mb-6">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<li key={item.name}>
<Link
to={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
</li>
);
})}
</ul>
</nav>
</div>
);
return (
<Sheet open={open} onOpenChange={onClose}>
<SheetContent side="left" className="p-0 w-64">
<SidebarContent />
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,405 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Send, Smile, AtSign } from "lucide-react";
import { useLiveChat } from "@/hooks/useLiveChat";
import { useAuthor } from "@/hooks/useAuthor";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { NoteContent } from "@/components/NoteContent";
import { genUserName } from "@/lib/genUserName";
import { nip19 } from "nostr-tools";
import { cn } from "@/lib/utils";
import type { NostrEvent } from "@nostrify/nostrify";
interface LiveChatProps {
liveEventId: string | null;
liveEvent: NostrEvent | null;
}
function ChatMessage({ message, isNew }: { message: NostrEvent, isNew?: boolean }) {
const author = useAuthor(message.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(message.pubkey);
// Check if current user is mentioned in this message
const isMentioned = user && message.tags.some(([tag, pubkey]) => tag === 'p' && pubkey === user.pubkey);
return (
<div className={cn(
"flex gap-3 p-3 hover:bg-muted/50 transition-all duration-300 w-full",
isNew && "animate-in slide-in-from-bottom-2 bg-primary/5 border-l-2 border-primary",
isMentioned && "bg-gradient-to-r from-blue-500/15 to-cyan-500/10 border-l-4 border-blue-500"
)}>
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback>{displayName[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm truncate">{displayName}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{new Date(message.created_at * 1000).toLocaleTimeString()}
</span>
</div>
<div className="text-sm break-words overflow-wrap-anywhere whitespace-pre-wrap" style={{ wordWrap: 'break-word', overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
<NoteContent event={message} className="break-words overflow-wrap-anywhere" />
</div>
</div>
</div>
);
}
// Component to get participant data with names for filtering
function useParticipantWithName(pubkey: string) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
return {
pubkey,
name: displayName,
picture: metadata?.picture,
isLoaded: !!author.data // Track if data is loaded
};
}
// Component for mention suggestions
function MentionSuggestion({
participant,
mentionSearch,
onSelect
}: {
participant: ChatParticipant;
mentionSearch: string;
onSelect: (pubkey: string, name: string) => void
}) {
const participantData = useParticipantWithName(participant.pubkey);
// Check if search matches
const searchTerm = mentionSearch.toLowerCase().trim();
const userName = participantData.name.toLowerCase();
// Hide if search doesn't match
if (searchTerm && !userName.includes(searchTerm)) return null;
// Calculate match quality for visual feedback
const isExactMatch = searchTerm && userName === searchTerm;
const startsWithMatch = searchTerm && userName.startsWith(searchTerm);
return (
<button
className={cn(
"flex items-center gap-2 w-full p-2 hover:bg-muted rounded text-left transition-colors",
isExactMatch && "bg-primary/10 border border-primary/20",
startsWithMatch && !isExactMatch && "bg-accent/50"
)}
onClick={() => onSelect(participant.pubkey, participantData.name)}
>
<Avatar className="h-6 w-6">
<AvatarImage src={participantData.picture} alt={participantData.name} />
<AvatarFallback>{participantData.name[0].toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{participantData.name}</span>
{isExactMatch && (
<span className="ml-auto text-xs text-primary">Exact match</span>
)}
</button>
);
}
// Common emojis for quick selection
const EMOJI_LIST = [
"🚀", "💎", "📺", "🎮", "🎵", "🎶", "📱", "💰", "🤯", "🎯", "🪙",
"😀", "😂", "🤣", "😊", "😍", "🥰", "😘", "😉", "😋",
"😜", "🤪", "🤔", "😏", "🙄", "😬", "😔", "😴", "😎", "🤓",
"😕", "😮", "😳", "🥺", "😢", "😭", "😱", "😤", "😡", "🤬",
"💀", "💩", "🤡", "👻", "👽", "🤖", "😺", "❤️", "🧡", "💛",
"💚", "💙", "💜", "🖤", "🤍", "💔", "💗", "💖", "👋", "👌",
"✌️", "🤞", "🤘", "👍", "👎", "👏", "🙌", "🤝", "🙏", "💪",
"🔥", "💯", "✨", "⚡", "🌟", "⭐", "🌈", "☀️", "🎉", "🎊",
"🎁", "🎄", "🎃", "✅", "❌", "❓", "❗", "💬", "💭",
];
interface ChatParticipant {
pubkey: string;
name: string;
picture?: string;
}
export function LiveChat({ liveEventId, liveEvent }: LiveChatProps) {
const { data: messages = [], isLoading } = useLiveChat(liveEventId);
const { user } = useCurrentUser();
const { mutate: publishMessage } = useNostrPublish();
const [newMessage, setNewMessage] = useState("");
const [newMessageIds, setNewMessageIds] = useState<Set<string>>(new Set());
const [emojiPopoverOpen, setEmojiPopoverOpen] = useState(false);
const [showMentions, setShowMentions] = useState(false);
const [mentionSearch, setMentionSearch] = useState("");
const [_selectedMentionIndex, setSelectedMentionIndex] = useState(0);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevMessagesLength = useRef(messages.length);
// Track participants from messages
const participants = useMemo(() => {
const participantMap = new Map<string, ChatParticipant>();
messages.forEach(msg => {
if (!participantMap.has(msg.pubkey)) {
participantMap.set(msg.pubkey, {
pubkey: msg.pubkey,
name: '', // Will be filled by useAuthor hooks
picture: undefined
});
}
});
return Array.from(participantMap.values());
}, [messages]);
// Track new messages and auto-scroll
useEffect(() => {
// Detect new messages
if (messages.length > prevMessagesLength.current) {
const newMessages = messages.slice(prevMessagesLength.current);
setNewMessageIds(prev => {
const newIds = new Set(prev);
newMessages.forEach(msg => newIds.add(msg.id));
return newIds;
});
// Auto-scroll to bottom
if (scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
}
prevMessagesLength.current = messages.length;
}, [messages]);
// Remove new message highlighting after a delay
useEffect(() => {
if (newMessageIds.size > 0) {
const timer = setTimeout(() => {
setNewMessageIds(new Set());
}, 3000); // Remove highlighting after 3 seconds
return () => clearTimeout(timer);
}
}, [newMessageIds]);
// Handle input changes and detect @ mentions
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setNewMessage(value);
// Check for @ mention at cursor position
const cursorPos = e.target.selectionStart || 0;
const textBeforeCursor = value.slice(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
// Check if we're in a mention (no space after @)
if (!textAfterAt.includes(' ')) {
setMentionSearch(textAfterAt.toLowerCase());
setShowMentions(true);
setSelectedMentionIndex(0);
return;
}
}
setShowMentions(false);
setMentionSearch("");
};
// Filter participants based on search - filtering will be done in the component
const filteredParticipants = useMemo(() => {
// Show most recent participants first, limited to prevent overwhelming UI
return participants.slice().reverse().slice(0, 8);
}, [participants]);
// Handle mention selection
const handleMentionSelect = (pubkey: string, name: string) => {
const cursorPos = inputRef.current?.selectionStart || 0;
const textBeforeCursor = newMessage.slice(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textBeforeAt = newMessage.slice(0, lastAtIndex);
const textAfterCursor = newMessage.slice(cursorPos);
// Replace with @name but store pubkey for later
const newText = `${textBeforeAt}@${name} ${textAfterCursor}`;
setNewMessage(newText);
// Store the mention for later processing
if (!inputRef.current) return;
inputRef.current.dataset.mentions = JSON.stringify({
...(inputRef.current.dataset.mentions ? JSON.parse(inputRef.current.dataset.mentions) : {}),
[name]: pubkey
});
}
setShowMentions(false);
setMentionSearch("");
inputRef.current?.focus();
};
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !liveEvent || !user) return;
const aTag = `30311:${liveEvent.pubkey}:${liveEvent.tags.find(([t]) => t === "d")?.[1]}`;
const tags: string[][] = [["a", aTag, "", "root"]];
// Process mentions and add p tags
const mentions = inputRef.current?.dataset.mentions ? JSON.parse(inputRef.current.dataset.mentions) : {};
let processedContent = newMessage.trim();
Object.entries(mentions).forEach(([name, pubkey]) => {
// Add p tag for each mention
tags.push(["p", pubkey as string]);
// Replace @name with nostr:npub in content
const npub = nip19.npubEncode(pubkey as string);
processedContent = processedContent.replace(new RegExp(`@${name}`, 'g'), `nostr:${npub}`);
});
publishMessage({
kind: 1311,
content: processedContent,
tags,
});
setNewMessage("");
setEmojiPopoverOpen(false);
setShowMentions(false);
// Clear stored mentions
if (inputRef.current) {
inputRef.current.dataset.mentions = "";
}
};
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3 flex-shrink-0">
<CardTitle className="text-lg">Live Chat</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 min-h-0 overflow-hidden">
<ScrollArea className="flex-1 px-4 min-h-0" ref={scrollAreaRef}>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading chat...
</div>
) : messages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No messages yet. Be the first to chat!
</div>
) : (
<div className="space-y-1 py-2 w-full overflow-hidden">
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isNew={newMessageIds.has(message.id)}
/>
))}
</div>
)}
</ScrollArea>
{user ? (
<form onSubmit={handleSendMessage} className="p-4 border-t flex-shrink-0 relative">
{/* Mention suggestions dropdown */}
{showMentions && (
<div className="absolute bottom-full left-4 right-4 mb-2 bg-popover border rounded-lg shadow-lg p-2 max-h-48 overflow-y-auto">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<AtSign className="h-3 w-3" />
{mentionSearch ? `Search: "${mentionSearch}"` : "Mention a participant"}
</div>
{filteredParticipants.length > 0 ? (
filteredParticipants.map((participant) => (
<MentionSuggestion
key={participant.pubkey}
participant={participant}
mentionSearch={mentionSearch}
onSelect={handleMentionSelect}
/>
))
) : (
<div className="text-xs text-muted-foreground text-center py-2">
No participants in chat yet
</div>
)}
</div>
)}
<div className="flex gap-2">
<Input
ref={inputRef}
value={newMessage}
onChange={handleInputChange}
placeholder="Type a message... (use @ to mention)"
className="flex-1"
disabled={!liveEvent}
onKeyDown={(e) => {
if (showMentions && e.key === 'Escape') {
setShowMentions(false);
setMentionSearch("");
}
}}
/>
<Popover open={emojiPopoverOpen} onOpenChange={setEmojiPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={!liveEvent}
>
<Smile className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2" align="end">
<div className="grid grid-cols-10 gap-1">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
type="button"
className="p-1 hover:bg-muted rounded text-lg transition-colors"
onClick={() => {
setNewMessage(prev => prev + emoji);
setEmojiPopoverOpen(false);
inputRef.current?.focus();
}}
>
{emoji}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Button type="submit" size="icon" disabled={!liveEvent || !newMessage.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</form>
) : (
<div className="p-4 border-t text-center text-sm text-muted-foreground flex-shrink-0">
Sign in to participate in chat
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,145 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, Wifi } from "lucide-react";
interface LiveStreamPlayerProps {
streamUrl: string;
title?: string | null;
image?: string | null;
participantCount?: number;
}
export function LiveStreamPlayer({
streamUrl,
title,
image,
participantCount = 0,
}: LiveStreamPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
if (!videoRef.current || !streamUrl) return;
const video = videoRef.current;
// Clean up previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (streamUrl.endsWith(".m3u8")) {
// Use HLS.js for .m3u8 streams
if (Hls.isSupported()) {
const hls = new Hls({
debug: false,
enableWorker: false,
lowLatencyMode: true,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 300,
startLevel: -1,
autoStartLoad: true,
});
hlsRef.current = hls;
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log("HLS media attached");
hls.loadSource(streamUrl);
});
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
console.log("HLS manifest parsed, levels:", data.levels.length);
// Don't autoplay, let user click play button
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error("HLS error:", data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log("Network error, trying to recover...");
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log("Media error, trying to recover...");
hls.recoverMediaError();
break;
default:
console.log("Fatal error, cannot recover");
hls.destroy();
break;
}
}
});
// Attach media first, then load source
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Fallback for Safari which has native HLS support
console.log("Using native HLS support");
video.src = streamUrl;
} else {
console.error("HLS is not supported in this browser");
}
} else {
// For non-HLS streams, use direct source
video.src = streamUrl;
}
// Cleanup function
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [streamUrl]);
return (
<Card className="overflow-hidden bg-black h-full">
<CardContent className="p-0 relative h-full">
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
<Badge variant="destructive" className="flex items-center gap-1">
<Wifi className="h-3 w-3" />
LIVE
</Badge>
{participantCount > 0 && (
<Badge variant="secondary" className="flex items-center gap-1">
<Users className="h-3 w-3" />
{participantCount}
</Badge>
)}
</div>
<div className="h-full bg-black">
{streamUrl.endsWith(".m3u8") ? (
<video
ref={videoRef}
controls
autoPlay
playsInline
className="w-full h-full object-contain"
poster={image || undefined}
>
Your browser does not support the video tag.
</video>
) : (
<iframe
src={streamUrl}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title || "Live Stream"}
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,136 @@
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Share2, ExternalLink, Calendar } from "lucide-react";
import { useToast } from "@/hooks/useToast";
import { nip19 } from "nostr-tools";
import type { NostrEvent } from "@nostrify/nostrify";
interface LiveStreamToolbarProps {
liveEvent: NostrEvent | null;
}
export function LiveStreamToolbar({ liveEvent }: LiveStreamToolbarProps) {
const { toast } = useToast();
const handleShare = async () => {
if (!liveEvent) return;
try {
const dTag = liveEvent.tags.find(([t]) => t === "d")?.[1];
if (!dTag) return;
const naddr = nip19.naddrEncode({
identifier: dTag,
pubkey: liveEvent.pubkey,
kind: 30311,
});
const shareUrl = `${window.location.origin}/${naddr}`;
if (navigator.share) {
await navigator.share({
title: "Live Stream",
text: "Join me on this live stream!",
url: shareUrl,
});
} else {
await navigator.clipboard.writeText(shareUrl);
toast({
title: "Link copied!",
description: "Stream link copied to clipboard",
});
}
} catch (error) {
console.error("Failed to share:", error);
toast({
title: "Share failed",
description: "Could not share the stream",
variant: "destructive",
});
}
};
if (!liveEvent) return null;
const titleTag = liveEvent.tags.find(([t]) => t === "title")?.[1];
const summaryTag = liveEvent.tags.find(([t]) => t === "summary")?.[1];
const startsTag = liveEvent.tags.find(([t]) => t === "starts")?.[1];
const formatDate = (timestamp: string) => {
try {
const date = new Date(parseInt(timestamp) * 1000);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return "";
}
};
return (
<Card className="mt-4">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Badge variant="destructive" className="flex items-center gap-1">
LIVE
</Badge>
{startsTag && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
Started {formatDate(startsTag)}
</div>
)}
</div>
{titleTag && (
<h3 className="font-semibold text-lg mb-1 truncate">{titleTag}</h3>
)}
{summaryTag && (
<p className="text-muted-foreground text-sm line-clamp-2">{summaryTag}</p>
)}
</div>
<div className="flex gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleShare}
className="gap-2"
>
<Share2 className="h-4 w-4" />
Share
</Button>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={`https://njump.me/${nip19.naddrEncode({
identifier: liveEvent.tags.find(([t]) => t === "d")?.[1] || "",
pubkey: liveEvent.pubkey,
kind: 30311,
})}`}
target="_blank"
rel="noopener noreferrer"
className="gap-2"
>
<ExternalLink className="h-4 w-4" />
View on njump
</a>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,6 +1,5 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
@ -63,10 +62,6 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))

131
src/hooks/useBlogPosts.ts Normal file
View File

@ -0,0 +1,131 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
export interface BlogPost {
id: string;
pubkey: string;
dTag: string;
title?: string;
summary?: string;
image?: string;
content: string;
publishedAt?: number;
createdAt: number;
updatedAt: number;
hashtags: string[];
isDraft: boolean;
}
function validateBlogPost(event: NostrEvent): boolean {
// Must be kind 30023 (published) or 30024 (draft)
if (![30023, 30024].includes(event.kind)) return false;
const dTag = event.tags.find(([tag]) => tag === 'd')?.[1];
// Must have d tag and content
return !!(dTag && event.content.trim());
}
function parseBlogPost(event: NostrEvent): BlogPost {
const dTag = event.tags.find(([tag]) => tag === 'd')?.[1] || '';
const title = event.tags.find(([tag]) => tag === 'title')?.[1];
const summary = event.tags.find(([tag]) => tag === 'summary')?.[1];
const image = event.tags.find(([tag]) => tag === 'image')?.[1];
const publishedAt = event.tags.find(([tag]) => tag === 'published_at')?.[1];
// Parse hashtags from t tags
const hashtags = event.tags
.filter(([tag]) => tag === 't')
.map(([_tag, hashtag]) => hashtag)
.filter(Boolean);
const isDraft = event.kind === 30024;
return {
id: event.id,
pubkey: event.pubkey,
dTag,
title,
summary,
image,
content: event.content,
publishedAt: publishedAt ? parseInt(publishedAt) : undefined,
createdAt: event.created_at,
updatedAt: event.created_at, // In NIP-23, created_at is the last update time
hashtags,
isDraft,
};
}
export function useBlogPosts() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['blog-posts', PATRICK_PUBKEY],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Query for Patrick's published long-form content (kind:30023)
const events = await nostr.query([
{
kinds: [30023], // Only published posts, not drafts
authors: [PATRICK_PUBKEY],
limit: 50,
}
], { signal });
// Validate and parse events
const validEvents = events.filter(validateBlogPost);
const blogPosts = validEvents.map(parseBlogPost);
// Sort by published date, then by creation/update time (newest first)
return blogPosts.sort((a, b) => {
// If both have published dates, sort by that
if (a.publishedAt && b.publishedAt) {
return b.publishedAt - a.publishedAt;
}
// If only one has published date, prioritize it
if (a.publishedAt && !b.publishedAt) return -1;
if (!a.publishedAt && b.publishedAt) return 1;
// Otherwise sort by update time (newest first)
return b.updatedAt - a.updatedAt;
});
},
});
}
// Hook to get a specific blog post by d-tag
export function useBlogPost(dTag: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['blog-post', PATRICK_PUBKEY, dTag],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Query for specific blog post
const events = await nostr.query([
{
kinds: [30023],
authors: [PATRICK_PUBKEY],
'#d': [dTag],
limit: 1,
}
], { signal });
if (events.length === 0) return null;
const event = events[0];
if (!validateBlogPost(event)) return null;
return parseBlogPost(event);
},
enabled: !!dTag,
});
}

32
src/hooks/useLiveChat.ts Normal file
View File

@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { useNostr } from "@nostrify/react";
export function useLiveChat(liveEventId: string | null) {
const { nostr } = useNostr();
return useQuery({
queryKey: ["live-chat", liveEventId],
queryFn: async (c) => {
if (!liveEventId) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
// Query for chat messages (kind 1311) that reference the live event
const messages = await nostr.query(
[
{
kinds: [1311],
"#a": [liveEventId],
limit: 500,
},
],
{ signal }
);
// Sort messages by creation time (oldest first for chat flow)
return messages.sort((a, b) => a.created_at - b.created_at);
},
enabled: !!liveEventId,
refetchInterval: 5000, // Refetch every 5 seconds for real-time chat
});
}

View File

@ -0,0 +1,97 @@
import { useQuery } from "@tanstack/react-query";
import { useNostr } from "@nostrify/react";
import type { NostrEvent } from "@nostrify/nostrify";
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
interface LiveStreamData {
event: NostrEvent | null;
isLive: boolean;
streamUrl: string | null;
title: string | null;
summary: string | null;
image: string | null;
participants: Array<{
pubkey: string;
role: string;
}>;
}
export function useLiveStream() {
const { nostr } = useNostr();
return useQuery({
queryKey: ["livestream", PATRICK_PUBKEY],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
// Query for live events where Patrick is a participant
const allEvents = await nostr.query(
[
{
kinds: [30311],
limit: 100,
},
],
{ signal }
);
// Filter for events where Patrick is a participant
const patrickEvents = allEvents.filter(event => {
// Check if Patrick's pubkey is in any p tag
return event.tags.some(tag =>
tag[0] === 'p' && tag[1] === PATRICK_PUBKEY
);
});
// Find the most recent live event where Patrick is a participant
const liveEvent = patrickEvents
.sort((a, b) => b.created_at - a.created_at)
.find((event) => {
const statusTag = event.tags.find(([tag]) => tag === "status");
return statusTag && statusTag[1] === "live";
});
if (!liveEvent) {
return {
event: null,
isLive: false,
streamUrl: null,
title: null,
summary: null,
image: null,
participants: [],
} as LiveStreamData;
}
// Extract data from the live event
const getTagValue = (tagName: string): string | null => {
const tag = liveEvent.tags.find(([name]) => name === tagName);
return tag ? tag[1] : null;
};
const participants = liveEvent.tags
.filter(([tag]) => tag === "p")
.map(([_, pubkey, __, role = "Participant"]) => ({
pubkey,
role,
}));
const streamData = {
event: liveEvent,
isLive: true,
streamUrl: getTagValue("streaming"),
title: getTagValue("title"),
summary: getTagValue("summary"),
image: getTagValue("image"),
participants,
} as LiveStreamData;
return streamData;
},
refetchInterval: 30000, // Refetch every 30 seconds to check if still live
});
}

139
src/hooks/usePhotos.ts Normal file
View File

@ -0,0 +1,139 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
export interface PhotoEvent {
id: string;
pubkey: string;
content: string;
createdAt: number;
imageUrls: string[];
hashtags: string[];
location?: string;
alt?: string;
}
function validatePhotoEvent(event: NostrEvent): boolean {
// Must be kind 20 (picture event) per NIP-68
if (event.kind !== 20) return false;
// Must have at least one imeta tag with image URL
const hasImetaImage = event.tags.some(([tag, ...values]) =>
tag === 'imeta' && values.some(v => v.startsWith('url ') && /\.(jpg|jpeg|png|gif|webp|apng|avif)/i.test(v))
);
return hasImetaImage;
}
function parsePhotoEvent(event: NostrEvent): PhotoEvent {
// Get title from NIP-68 title tag
const title = event.tags.find(([tag]) => tag === 'title')?.[1];
// Parse hashtags from t tags
const hashtags = event.tags
.filter(([tag]) => tag === 't')
.map(([_tag, hashtag]) => hashtag)
.filter(Boolean);
// Extract location from location tag or g tag (geohash)
const location = event.tags.find(([tag]) => tag === 'location')?.[1]
|| event.tags.find(([tag]) => tag === 'g')?.[1];
// Extract image URLs from imeta tags (NIP-68)
const imageUrls: string[] = [];
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ') && /\.(jpg|jpeg|png|gif|webp|apng|avif)/i.test(item)) {
imageUrls.push(item.substring(4));
}
}
}
// Get alt text from imeta tags (each image can have its own alt text)
let alt = title; // Use title as fallback alt text
if (imetaTags.length > 0) {
// Get alt from first imeta tag if available
for (const imetaTag of imetaTags) {
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('alt ')) {
alt = item.substring(4);
break;
}
}
if (alt !== title) break; // Found alt text, stop looking
}
}
return {
id: event.id,
pubkey: event.pubkey,
content: event.content, // Description from NIP-68
createdAt: event.created_at,
imageUrls,
hashtags,
location,
alt,
};
}
export function usePhotos() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['photos', PATRICK_PUBKEY],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Query for Patrick's picture events (kind 20) per NIP-68
const events = await nostr.query([
{
kinds: [20],
authors: [PATRICK_PUBKEY],
limit: 100,
}
], { signal });
// Filter and validate picture events
const photoEvents = events
.filter(validatePhotoEvent)
.map(parsePhotoEvent);
// Sort by creation time (newest first)
return photoEvents.sort((a, b) => b.createdAt - a.createdAt);
},
});
}
export function usePhoto(eventId: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['photo', eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query([
{
kinds: [20],
ids: [eventId],
limit: 1,
}
], { signal });
if (events.length === 0) return null;
const event = events[0];
if (!validatePhotoEvent(event)) return null;
return parsePhotoEvent(event);
},
enabled: !!eventId,
});
}

View File

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
interface RelayPreferences {
relays: string[];
event?: NostrEvent;
}
/**
* Hook to fetch a user's kind 10050 relay preferences for DMs
* @param pubkey - The public key of the user
* @returns Query result with the user's preferred DM relays
*/
export function useRelayPreferences(pubkey: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['relay-preferences', pubkey],
queryFn: async (c) => {
if (!pubkey) {
return { relays: [] };
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query([
{
kinds: [10050],
authors: [pubkey],
limit: 1,
}
], { signal });
const latestEvent = events[0];
if (!latestEvent) {
return { relays: [] };
}
// Extract relay URLs from 'relay' tags
const relays = latestEvent.tags
.filter(([tag]) => tag === 'relay')
.map(([, url]) => url)
.filter((url): url is string => typeof url === 'string' && url.length > 0);
return {
relays,
event: latestEvent,
} as RelayPreferences;
},
enabled: !!pubkey,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

215
src/hooks/useVideos.ts Normal file
View File

@ -0,0 +1,215 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
export interface VideoEvent {
id: string;
pubkey: string;
kind: 21 | 22; // 21 = normal video, 22 = short video
content: string;
title?: string;
publishedAt?: number;
createdAt: number;
duration?: number;
hashtags: string[];
participants: string[];
videoUrls: VideoVariant[];
thumbnailUrls: string[];
contentWarning?: string;
alt?: string;
segments: VideoSegment[];
references: string[];
}
export interface VideoVariant {
url: string;
dimensions?: string;
mimeType?: string;
hash?: string;
fallbacks: string[];
}
export interface VideoSegment {
start: string;
end: string;
title: string;
thumbnailUrl?: string;
}
function validateVideoEvent(event: NostrEvent): boolean {
// Must be kind 21 (normal video) or 22 (short video)
if (![21, 22].includes(event.kind)) return false;
// Must have title tag
const title = event.tags.find(([tag]) => tag === 'title')?.[1];
// Must have at least one imeta tag with video URL
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
const hasVideoUrl = imetaTags.some(tag =>
tag.some(item => item.startsWith('url ')) &&
tag.some(item => item.startsWith('m ') && item.includes('video/'))
);
return !!(title && hasVideoUrl);
}
function parseVideoEvent(event: NostrEvent): VideoEvent {
const title = event.tags.find(([tag]) => tag === 'title')?.[1];
const publishedAt = event.tags.find(([tag]) => tag === 'published_at')?.[1];
const duration = event.tags.find(([tag]) => tag === 'duration')?.[1];
const contentWarning = event.tags.find(([tag]) => tag === 'content-warning')?.[1];
const alt = event.tags.find(([tag]) => tag === 'alt')?.[1];
// Parse hashtags from t tags
const hashtags = event.tags
.filter(([tag]) => tag === 't')
.map(([_tag, hashtag]) => hashtag)
.filter(Boolean);
// Parse participants from p tags
const participants = event.tags
.filter(([tag]) => tag === 'p')
.map(([_tag, pubkey]) => pubkey)
.filter(Boolean);
// Parse video URLs from imeta tags
const videoUrls: VideoVariant[] = [];
const thumbnailUrls: string[] = [];
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
let url = '';
let dimensions = '';
let mimeType = '';
let hash = '';
const fallbacks: string[] = [];
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ')) {
url = item.substring(4);
} else if (item.startsWith('dim ')) {
dimensions = item.substring(4);
} else if (item.startsWith('m ')) {
mimeType = item.substring(2);
} else if (item.startsWith('x ')) {
hash = item.substring(2);
} else if (item.startsWith('fallback ')) {
fallbacks.push(item.substring(9));
} else if (item.startsWith('image ')) {
thumbnailUrls.push(item.substring(6));
}
}
if (url && mimeType?.includes('video/')) {
videoUrls.push({
url,
dimensions,
mimeType,
hash,
fallbacks,
});
}
}
// Parse segments
const segments: VideoSegment[] = event.tags
.filter(([tag]) => tag === 'segment')
.map(([_tag, start, end, title, thumbnailUrl]) => ({
start: start || '',
end: end || '',
title: title || '',
thumbnailUrl,
}))
.filter(segment => segment.start && segment.end && segment.title);
// Parse references
const references = event.tags
.filter(([tag]) => tag === 'r')
.map(([_tag, url]) => url)
.filter(Boolean);
return {
id: event.id,
pubkey: event.pubkey,
kind: event.kind as 21 | 22,
content: event.content,
title,
publishedAt: publishedAt ? parseInt(publishedAt) : undefined,
createdAt: event.created_at,
duration: duration ? parseInt(duration) : undefined,
hashtags,
participants,
videoUrls,
thumbnailUrls: [...new Set(thumbnailUrls)], // Remove duplicates
contentWarning,
alt,
segments,
references,
};
}
export function useVideos() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['videos', PATRICK_PUBKEY],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Query for Patrick's video events (kind 21 and 22)
const events = await nostr.query([
{
kinds: [21, 22], // Normal and short videos
authors: [PATRICK_PUBKEY],
limit: 50,
}
], { signal });
// Validate and parse events
const validEvents = events.filter(validateVideoEvent);
const videos = validEvents.map(parseVideoEvent);
// Sort by published date, then by creation time (newest first)
return videos.sort((a, b) => {
if (a.publishedAt && b.publishedAt) {
return b.publishedAt - a.publishedAt;
}
if (a.publishedAt && !b.publishedAt) return -1;
if (!a.publishedAt && b.publishedAt) return 1;
return b.createdAt - a.createdAt;
});
},
});
}
export function useVideo(eventId: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['video', eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query([
{
kinds: [21, 22],
ids: [eventId],
limit: 1,
}
], { signal });
if (events.length === 0) return null;
const event = events[0];
if (!validateVideoEvent(event)) return null;
return parseVideoEvent(event);
},
enabled: !!eventId,
});
}

View File

@ -1,3 +1,6 @@
/* Syntax highlighting for code blocks */
@import 'highlight.js/styles/github-dark.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -5,25 +8,25 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 10%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 0 0% 10%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 30 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 249 100% 64%;
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 30 100% 50%;
--accent-foreground: 0 0% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
@ -52,26 +55,26 @@
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--background: 0 0% 7%;
--foreground: 0 0% 95%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 0 0% 10%;
--card-foreground: 0 0% 95%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 0 0% 10%;
--popover-foreground: 0 0% 95%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: 30 100% 50%;
--primary-foreground: 0 0% 10%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 249 100% 64%;
--secondary-foreground: 0 0% 10%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 0 0% 15%;
--muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 30 100% 50%;
--accent-foreground: 0 0% 10%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
@ -98,4 +101,9 @@
body {
@apply bg-background text-foreground;
}
}
/* Set default to dark mode */
:root {
color-scheme: dark;
}

View File

@ -7,8 +7,8 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import App from './App.tsx';
import './index.css';
// FIXME: a custom font should be used. Eg:
// import '@fontsource-variable/<font-name>';
// Import custom font
import '@fontsource-variable/outfit';
createRoot(document.getElementById("root")!).render(
<ErrorBoundary>

254
src/pages/Blog.tsx Normal file
View File

@ -0,0 +1,254 @@
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;

214
src/pages/BlogPost.tsx Normal file
View File

@ -0,0 +1,214 @@
import { useParams } from 'react-router-dom';
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 { Button } from '@/components/ui/button';
import { useBlogPost } from '@/hooks/useBlogPosts';
import { MarkdownContent } from '@/components/MarkdownContent';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { Calendar, Clock, ArrowLeft, Hash } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
const BlogPost = () => {
const { naddr } = useParams<{ naddr: string }>();
let dTag = '';
let isValidNaddr = false;
if (naddr) {
try {
const decoded = nip19.decode(naddr);
if (decoded.type === 'naddr' && decoded.data.kind === 30023) {
dTag = decoded.data.identifier;
isValidNaddr = true;
}
} catch (error) {
console.error('Invalid naddr:', error);
}
}
const { data: blogPost, isLoading, error } = useBlogPost(dTag);
useSeoMeta({
title: blogPost?.title ? `${blogPost.title} - Patrick Ulrich` : 'Blog Post - Patrick Ulrich',
description: blogPost?.summary || blogPost?.content.slice(0, 160) || 'Read this blog post by Patrick Ulrich',
});
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const estimateReadTime = (content: string) => {
const words = content.trim().split(/\s+/).length;
const minutes = Math.ceil(words / 200);
return `${minutes} min read`;
};
if (!isValidNaddr) {
return (
<MainLayout>
<div className="container mx-auto px-4 py-8">
<Card>
<CardContent className="py-12 px-8 text-center">
<h1 className="text-2xl font-bold mb-4">Invalid Article Link</h1>
<p className="text-muted-foreground mb-6">
The article link you followed is not valid or properly formatted.
</p>
<Button asChild>
<Link to="/blog">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Link>
</Button>
</CardContent>
</Card>
</div>
</MainLayout>
);
}
if (isLoading) {
return (
<MainLayout>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<Skeleton className="h-4 w-24 mb-4" />
<Skeleton className="h-10 w-3/4 mb-4" />
<div className="flex gap-4 mb-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
<Card>
<CardContent className="p-8">
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</CardContent>
</Card>
</div>
</div>
</MainLayout>
);
}
if (error || !blogPost) {
return (
<MainLayout>
<div className="container mx-auto px-4 py-8">
<Card>
<CardContent className="py-12 px-8 text-center">
<h1 className="text-2xl font-bold mb-4">Article Not Found</h1>
<p className="text-muted-foreground mb-6">
The article you're looking for could not be found. It may have been moved or deleted.
</p>
<Button asChild>
<Link to="/blog">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Link>
</Button>
</CardContent>
</Card>
</div>
</MainLayout>
);
}
// Create a mock event for CommentsSection
const mockEvent = {
id: blogPost.id,
pubkey: blogPost.pubkey,
created_at: blogPost.createdAt,
kind: 30023,
tags: [['d', blogPost.dTag]],
content: blogPost.content,
sig: ''
};
return (
<MainLayout>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Back button */}
<div className="mb-6">
<Button variant="ghost" asChild className="gap-2">
<Link to="/blog">
<ArrowLeft className="w-4 h-4" />
Back to Blog
</Link>
</Button>
</div>
{/* Article header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight">
{blogPost.title || `Untitled Post #${blogPost.dTag}`}
</h1>
{blogPost.summary && (
<p className="text-xl text-muted-foreground mb-6 leading-relaxed">
{blogPost.summary}
</p>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground mb-6">
<span className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
{formatDate(blogPost.publishedAt || blogPost.createdAt)}
</span>
<span className="flex items-center gap-2">
<Clock className="h-4 w-4" />
{estimateReadTime(blogPost.content)}
</span>
</div>
{blogPost.hashtags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-8">
{blogPost.hashtags.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1">
<Hash className="h-3 w-3" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Article content */}
<Card className="mb-12">
<CardContent className="p-8">
<MarkdownContent
content={blogPost.content}
className="text-base leading-relaxed"
/>
</CardContent>
</Card>
{/* Comments */}
<CommentsSection
root={mockEvent}
title="Discussion"
emptyStateMessage="Start the conversation"
emptyStateSubtitle="Share your thoughts about this article"
/>
</div>
</div>
</MainLayout>
);
};
export default BlogPost;

View File

@ -1,24 +1,576 @@
import { useSeoMeta } from '@unhead/react';
import { MainLayout } from '@/components/layout/MainLayout';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { MessageDialog } from '@/components/MessageDialog';
import { LiveStreamPlayer } from '@/components/livestream/LiveStreamPlayer';
import { LiveChat } from '@/components/livestream/LiveChat';
import { LiveStreamToolbar } from '@/components/livestream/LiveStreamToolbar';
import { useLiveStream } from '@/hooks/useLiveStream';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { NoteContent } from '@/components/NoteContent';
import {
Radio,
Calendar,
Music,
Video,
Gamepad2,
ChevronRight,
Terminal,
ExternalLink,
MessageCircle,
Image as ImageIcon,
Clock,
ExternalLink as LinkIcon,
Sparkles
} from 'lucide-react';
// FIXME: Update this page (the content is just a fallback if you fail to update the page)
// Patrick's pubkey
const PATRICK_PUBKEY = "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d";
interface ActivityItem {
id: string;
type: 'post' | 'photo' | 'video';
timestamp: number;
event: NostrEvent;
content: string;
title?: string;
mediaUrl?: string;
hashtags: string[];
}
// Hook for recent activity feed
function useRecentActivity() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['recent-activity', PATRICK_PUBKEY],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(4000)]);
// Query multiple event types: posts (kind 1), photos (kind 20), videos (kind 21, 22)
const events = await nostr.query([
{
kinds: [1, 20, 21, 22],
authors: [PATRICK_PUBKEY],
limit: 50,
}
], { signal });
const activityItems: ActivityItem[] = [];
for (const event of events) {
// Skip replies/comments for kind 1 posts (posts that have 'e' tags referencing other events)
if (event.kind === 1 && event.tags.some(([tag]) => tag === 'e')) {
continue;
}
let type: ActivityItem['type'];
const content = event.content;
let title: string | undefined;
let mediaUrl: string | undefined;
// Parse hashtags from t tags
const hashtags = event.tags
.filter(([tag]) => tag === 't')
.map(([_tag, hashtag]) => hashtag)
.filter(Boolean);
if (event.kind === 1) {
type = 'post';
} else if (event.kind === 20) {
type = 'photo';
title = event.tags.find(([tag]) => tag === 'title')?.[1];
// Get first image URL from imeta tags
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ') && /\.(jpg|jpeg|png|gif|webp|apng|avif)/i.test(item)) {
mediaUrl = item.substring(4);
break;
}
}
if (mediaUrl) break;
}
} else if (event.kind === 21 || event.kind === 22) {
type = 'video';
title = event.tags.find(([tag]) => tag === 'title')?.[1];
// Get thumbnail from imeta tags
const imetaTags = event.tags.filter(([tag]) => tag === 'imeta');
for (const imetaTag of imetaTags) {
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('image ')) {
mediaUrl = item.substring(6);
break;
}
}
if (mediaUrl) break;
}
} else {
continue;
}
activityItems.push({
id: event.id,
type,
timestamp: event.created_at,
event,
content,
title,
mediaUrl,
hashtags,
});
}
// Sort by timestamp (newest first) and limit to 5 items
return activityItems
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 5);
},
});
}
const Index = () => {
useSeoMeta({
title: 'Welcome to Your Blank App',
description: 'A modern Nostr client application built with React, TailwindCSS, and Nostrify.',
title: 'Patrick Ulrich - Digital Sovereignty Projects',
description: 'Building tools to empower digital sovereignty. Creator of GoodMorningBitcoin, NostrCal, NostrMusic, Vlogstr, and RustySats.',
});
const { data: liveStreamData } = useLiveStream();
const { data: recentActivity, isLoading: activityLoading } = useRecentActivity();
// Check if livestream should show
const shouldShowLiveStream = liveStreamData?.isLive && liveStreamData?.streamUrl;
// Generate live event ID for chat
const liveEventId = liveStreamData?.event
? `30311:${liveStreamData.event.pubkey}:${
liveStreamData.event.tags.find(([t]) => t === "d")?.[1]
}`
: null;
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
} else if (diffInHours < 24 * 7) {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
};
const getJumbleUrl = (event: NostrEvent) => {
const noteId = nip19.noteEncode(event.id);
return `https://jumble.social/${noteId}`;
};
const getActivityIcon = (type: ActivityItem['type']) => {
switch (type) {
case 'post':
return MessageCircle;
case 'photo':
return ImageIcon;
case 'video':
return Video;
}
};
const getActivityTypeLabel = (type: ActivityItem['type']) => {
switch (type) {
case 'post':
return 'Post';
case 'photo':
return 'Photo';
case 'video':
return 'Video';
}
};
const truncateContent = (content: string, maxLength = 150) => {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength).trim() + '...';
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">
Welcome to Your Blank App
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start building your amazing project here!
</p>
<MainLayout>
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
{/* Livestream or Hero Section */}
{shouldShowLiveStream ? (
<section className="container mx-auto px-4 py-8">
<div className="mb-6">
<h2 className="text-3xl font-bold mb-2 flex items-center gap-2">
<Sparkles className="h-8 w-8 text-primary" />
Live Now
</h2>
<p className="text-muted-foreground">
Join Patrick's live stream
</p>
</div>
<div className="space-y-4">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="h-[500px] lg:h-[400px] xl:h-[500px]">
<LiveStreamPlayer
streamUrl={liveStreamData.streamUrl!}
title={liveStreamData.title}
image={liveStreamData.image}
participantCount={liveStreamData.participants.length}
/>
</div>
</div>
<div className="lg:col-span-1">
<div className="h-[500px] lg:h-[400px] xl:h-[500px]">
<LiveChat liveEventId={liveEventId} liveEvent={liveStreamData.event} />
</div>
</div>
</div>
{/* Toolbar below stream and chat */}
<LiveStreamToolbar liveEvent={liveStreamData.event} />
</div>
</section>
) : (
/* Hero Section */
<section className="relative overflow-hidden flex items-center" style={{ minHeight: '80vh' }}>
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-secondary/10" />
<div className="container relative mx-auto px-4">
<div className="flex flex-col items-center text-center space-y-8">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight">
Patrick Ulrich
</h1>
<p className="text-xl md:text-2xl text-muted-foreground max-w-2xl">
Building tools to empower digital sovereignty and individual freedom
</p>
<div className="flex justify-center">
<MessageDialog recipientPubkey={PATRICK_PUBKEY}>
<Button size="lg" className="gap-2">
<Terminal className="w-4 h-4" />
Send Message
<ChevronRight className="w-4 h-4" />
</Button>
</MessageDialog>
</div>
</div>
</div>
</section>
)}
{/* Recent Activity Feed */}
<section className="py-20 px-4 bg-muted/20">
<div className="container mx-auto max-w-4xl">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Recent Activity</h2>
<p className="text-lg text-muted-foreground">
Latest posts, photos, and videos from the decentralized web
</p>
</div>
{activityLoading ? (
<div className="space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted animate-pulse rounded w-1/4" />
<div className="h-4 bg-muted animate-pulse rounded w-3/4" />
<div className="h-4 bg-muted animate-pulse rounded w-1/2" />
</div>
</div>
</Card>
))}
</div>
) : recentActivity && recentActivity.length > 0 ? (
<div className="space-y-6">
{recentActivity.map((item) => {
const Icon = getActivityIcon(item.type);
return (
<Card key={item.id} className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-primary/20">
<CardContent className="p-6">
<div className="flex items-start gap-4">
{/* Activity Icon & Media Preview */}
<div className="flex-shrink-0">
{item.mediaUrl ? (
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-muted">
<img
src={item.mediaUrl}
alt={item.title || 'Media preview'}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Icon className="w-6 h-6 text-white" />
</div>
</div>
) : (
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Icon className="w-8 h-8 text-primary" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-primary">
{getActivityTypeLabel(item.type)}
</span>
<span className="text-sm text-muted-foreground"></span>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDate(item.timestamp)}
<a
href={getJumbleUrl(item.event)}
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-muted-foreground hover:text-primary transition-colors"
title="Open in Jumble.social"
>
<LinkIcon className="w-3 h-3" />
</a>
</div>
</div>
{item.title && (
<h3 className="text-lg font-semibold mb-2 line-clamp-1">
{item.title}
</h3>
)}
{item.content && (
<div className="text-muted-foreground mb-3 line-clamp-3">
{item.type === 'post' ? (
<NoteContent event={item.event} className="text-sm" />
) : (
<p>{truncateContent(item.content)}</p>
)}
</div>
)}
{item.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{item.hashtags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-1 rounded-md bg-secondary/80 text-secondary-foreground text-xs"
>
#{tag}
</span>
))}
{item.hashtags.length > 3 && (
<span className="inline-flex items-center px-2 py-1 rounded-md bg-secondary/80 text-secondary-foreground text-xs">
+{item.hashtags.length - 3}
</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3">
{item.type === 'photo' && (
<Link
to="/photos"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ImageIcon className="w-3 h-3" />
View in Gallery
</Link>
)}
{item.type === 'video' && (
<Link
to="/videos"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-primary transition-colors"
>
<Video className="w-3 h-3" />
Watch Video
</Link>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<MessageCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
No recent activity found. Check back soon for new posts, photos, and videos!
</p>
</CardContent>
</Card>
)}
</div>
</section>
{/* Projects Section */}
<section className="py-20 px-4">
<div className="container mx-auto">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
My Projects
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<a
href="https://goodmorningbitcoin.com"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-primary/20 cursor-pointer h-full">
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Radio className="w-6 h-6 text-primary" />
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<h3 className="text-xl font-semibold">Good Morning Bitcoin</h3>
<p className="text-muted-foreground">
Internet radio station and daily Bitcoin news roundup. Learn Bitcoin basics and stay updated with the latest Bitcoin developments.
</p>
</CardContent>
</Card>
</a>
<a
href="https://nostrcal.com"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-secondary/20 cursor-pointer h-full">
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-lg bg-secondary/10 flex items-center justify-center group-hover:bg-secondary/20 transition-colors">
<Calendar className="w-6 h-6 text-secondary" />
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-secondary transition-colors" />
</div>
<h3 className="text-xl font-semibold">NostrCal</h3>
<p className="text-muted-foreground">
Decentralized calendar application built on Nostr using NIP-52. Create and manage events in a censorship-resistant way.
</p>
</CardContent>
</Card>
</a>
<a
href="https://nostrmusic.com"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-primary/20 cursor-pointer h-full">
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Music className="w-6 h-6 text-primary" />
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<h3 className="text-xl font-semibold">NostrMusic</h3>
<p className="text-muted-foreground">
Music streaming platform on Nostr where artists can share their music and connect directly with listeners through zaps.
</p>
</CardContent>
</Card>
</a>
<a
href="https://vlogstr.com"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-secondary/20 cursor-pointer h-full">
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-lg bg-secondary/10 flex items-center justify-center group-hover:bg-secondary/20 transition-colors">
<Video className="w-6 h-6 text-secondary" />
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-secondary transition-colors" />
</div>
<h3 className="text-xl font-semibold">Vlogstr</h3>
<p className="text-muted-foreground">
Video blogging platform on Nostr for decentralized video content creation and sharing without platform censorship.
</p>
</CardContent>
</Card>
</a>
<a
href="https://rustysats.com"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-primary/20 cursor-pointer h-full">
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Gamepad2 className="w-6 h-6 text-primary" />
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<h3 className="text-xl font-semibold">RustySats</h3>
<p className="text-muted-foreground">
Innovative Rust gaming server where players earn real Bitcoin through gameplay, combining survival gaming with Bitcoin education.
</p>
</CardContent>
</Card>
</a>
<Card className="group hover:shadow-lg transition-all duration-300 border-muted hover:border-secondary/20">
<CardContent className="p-6 space-y-4 text-center">
<div className="w-12 h-12 rounded-lg bg-muted/50 flex items-center justify-center mx-auto">
<Terminal className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-muted-foreground">More Coming Soon</h3>
<p className="text-muted-foreground">
Always building new tools to advance digital sovereignty and Bitcoin adoption.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
</div>
</div>
</MainLayout>
);
};

View File

@ -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 <div>Event placeholder</div>;
case 'naddr':
// AI agent should implement addressable event view here
return <div>Addressable event placeholder</div>;
// Handle blog articles and other addressable events
return <BlogPost />;
default:
return <NotFound />;

241
src/pages/Photos.tsx Normal file
View File

@ -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<PhotoEvent | null>(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 (
<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">
<ImageIcon className="h-10 w-10 text-primary" />
Photos
</h1>
<p className="text-lg text-muted-foreground">
Photo gallery featuring picture-first posts. Images shared using NIP-68 on the decentralized network.
</p>
</div>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<Card key={i} className="overflow-hidden">
<Skeleton className="aspect-square" />
<CardContent className="p-4">
<Skeleton className="h-4 w-3/4 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<div className="text-center py-12">
<ImageIcon className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">Failed to load photos</p>
<RelaySelector />
</div>
) : !photos?.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">
<ImageIcon className="h-12 w-12 text-muted-foreground mx-auto" />
<p className="text-muted-foreground">
No photos found. Try another relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
</div>
) : (
<div className="space-y-8">
{/* Photo Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.map((photo) => (
<Card
key={photo.id}
className="group hover:shadow-lg transition-shadow overflow-hidden cursor-pointer"
onClick={() => setSelectedPhoto(photo)}
>
<div className="relative aspect-square bg-muted">
{photo.imageUrls[0] ? (
<img
src={photo.imageUrls[0]}
alt={photo.alt || cleanContent(photo.content) || 'Photo'}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="h-12 w-12 text-muted-foreground" />
</div>
)}
{photo.imageUrls.length > 1 && (
<div className="absolute top-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
+{photo.imageUrls.length - 1}
</div>
)}
</div>
<CardContent className="p-4">
<div className="space-y-2">
{cleanContent(photo.content) && (
<p className="text-sm line-clamp-2">
{cleanContent(photo.content)}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDate(photo.createdAt)}
{photo.location && (
<>
<MapPin className="h-3 w-3 ml-2" />
<span className="truncate">{photo.location}</span>
</>
)}
</div>
{photo.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1">
{photo.hashtags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
<Hash className="h-2 w-2 mr-1" />
{tag}
</Badge>
))}
{photo.hashtags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{photo.hashtags.length - 2}
</Badge>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Photo Modal */}
{selectedPhoto && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<div
className="max-w-4xl max-h-full w-full bg-background rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="grid md:grid-cols-2 max-h-[90vh]">
{/* Image */}
<div className="relative bg-muted min-h-[300px] md:min-h-[400px]">
{selectedPhoto.imageUrls.length === 1 ? (
<img
src={selectedPhoto.imageUrls[0]}
alt={selectedPhoto.alt || cleanContent(selectedPhoto.content) || 'Photo'}
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full overflow-auto">
<div className="space-y-4 p-4">
{selectedPhoto.imageUrls.map((url, index) => (
<img
key={index}
src={url}
alt={`${selectedPhoto.alt || 'Photo'} ${index + 1}`}
className="w-full object-contain rounded"
/>
))}
</div>
</div>
)}
</div>
{/* Details */}
<div className="p-6 overflow-y-auto">
<div className="space-y-4">
{cleanContent(selectedPhoto.content) && (
<div>
<h3 className="text-lg font-semibold mb-2">Description</h3>
<p className="text-muted-foreground whitespace-pre-wrap">
{cleanContent(selectedPhoto.content)}
</p>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-2">Details</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
{formatDate(selectedPhoto.createdAt)}
</div>
{selectedPhoto.location && (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
{selectedPhoto.location}
</div>
)}
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
{selectedPhoto.imageUrls.length} image{selectedPhoto.imageUrls.length !== 1 ? 's' : ''}
</div>
</div>
</div>
{selectedPhoto.hashtags.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{selectedPhoto.hashtags.map((tag) => (
<Badge key={tag} variant="outline">
<Hash className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</MainLayout>
);
};
export default Photos;

390
src/pages/Videos.tsx Normal file
View File

@ -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<VideoEvent | null>(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 (
<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">
<Video className="h-10 w-10 text-primary" />
Videos
</h1>
<p className="text-lg text-muted-foreground">
Video content on Bitcoin, Nostr, and digital sovereignty. All videos are published on Nostr using NIP-71.
</p>
</div>
{isLoading ? (
<div className="space-y-8">
{/* Loading Skeleton */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<Skeleton className="aspect-video" />
<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" />
</CardContent>
</Card>
))}
</div>
</div>
) : error ? (
<div className="text-center py-12">
<Video className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">Failed to load videos</p>
<RelaySelector />
</div>
) : !videos?.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">
<Video className="h-12 w-12 text-muted-foreground mx-auto" />
<p className="text-muted-foreground">
No videos found. Try another relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
</div>
) : (
<div className="space-y-12">
{/* Normal Videos */}
{normalVideos.length > 0 && (
<section>
<h2 className="text-2xl font-bold mb-6">Videos</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{normalVideos.map((video) => (
<Card key={video.id} className="group hover:shadow-lg transition-shadow overflow-hidden">
<div className="relative aspect-video bg-muted">
{getBestThumbnail(video) ? (
<img
src={getBestThumbnail(video)}
alt={video.title || 'Video thumbnail'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="h-12 w-12 text-muted-foreground" />
</div>
)}
<div
className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors flex items-center justify-center cursor-pointer"
onClick={() => setSelectedVideo(video)}
>
<Button
size="icon"
className="h-12 w-12 rounded-full bg-background/90 hover:bg-background text-foreground"
>
<Play className="h-6 w-6 ml-1" />
</Button>
</div>
{video.duration && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
{formatDuration(video.duration)}
</div>
)}
{video.contentWarning && (
<div className="absolute top-2 left-2 bg-destructive text-destructive-foreground px-2 py-1 rounded text-xs flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
NSFW
</div>
)}
</div>
<CardHeader>
<CardTitle className="line-clamp-2">
{video.title || 'Untitled Video'}
</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(video.publishedAt || video.createdAt)}
</span>
{video.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(video.duration)}
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{video.content && (
<p className="text-muted-foreground line-clamp-3 text-sm">
{video.content}
</p>
)}
{video.participants.length > 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-3 w-3" />
<span>{video.participants.length} participant{video.participants.length !== 1 ? 's' : ''}</span>
</div>
)}
{video.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1">
{video.hashtags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
<Hash className="h-2 w-2 mr-1" />
{tag}
</Badge>
))}
{video.hashtags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{video.hashtags.length - 3} more
</Badge>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
</section>
)}
{/* Short Videos */}
{shortVideos.length > 0 && (
<section>
<h2 className="text-2xl font-bold mb-6">Shorts</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{shortVideos.map((video) => (
<Card key={video.id} className="group hover:shadow-lg transition-shadow overflow-hidden">
<div className="relative aspect-[9/16] bg-muted">
{getBestThumbnail(video) ? (
<img
src={getBestThumbnail(video)}
alt={video.title || 'Video thumbnail'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div
className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors flex items-center justify-center cursor-pointer"
onClick={() => setSelectedVideo(video)}
>
<Button
size="icon"
className="h-10 w-10 rounded-full bg-background/90 hover:bg-background text-foreground"
>
<Play className="h-5 w-5 ml-0.5" />
</Button>
</div>
{video.duration && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs">
{formatDuration(video.duration)}
</div>
)}
{video.contentWarning && (
<div className="absolute top-2 left-2 bg-destructive text-destructive-foreground px-1.5 py-0.5 rounded text-xs">
<AlertTriangle className="h-3 w-3" />
</div>
)}
</div>
<CardContent className="p-3">
<h3 className="font-semibold text-sm line-clamp-2 mb-2">
{video.title || 'Untitled Short'}
</h3>
{video.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1">
{video.hashtags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
#{tag}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
</section>
)}
</div>
)}
{/* Video Player Modal */}
{selectedVideo && (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedVideo(null)}
>
<div
className="relative max-w-5xl w-full bg-black rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-60 text-white hover:bg-white/20"
onClick={() => setSelectedVideo(null)}
>
<X className="h-5 w-5" />
</Button>
{/* Video Player */}
<div className="aspect-video w-full">
{getBestVideoUrl(selectedVideo) ? (
<video
className="w-full h-full"
controls
autoPlay
playsInline
poster={getBestThumbnail(selectedVideo)}
>
<source
src={getBestVideoUrl(selectedVideo)}
type={selectedVideo.videoUrls[0]?.mimeType || 'video/mp4'}
/>
{/* Fallback sources */}
{selectedVideo.videoUrls[0]?.fallbacks?.map((fallbackUrl, index) => (
<source key={index} src={fallbackUrl} />
))}
Your browser does not support the video tag.
</video>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<div className="text-center">
<Video className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Video not available</p>
</div>
</div>
)}
</div>
{/* Video Info */}
<div className="p-6 bg-background">
<div className="space-y-4">
<div>
<h2 className="text-xl font-bold mb-2">
{selectedVideo.title || 'Untitled Video'}
</h2>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(selectedVideo.publishedAt || selectedVideo.createdAt)}
</span>
{selectedVideo.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(selectedVideo.duration)}
</span>
)}
</div>
</div>
{selectedVideo.content && (
<div>
<h3 className="text-lg font-semibold mb-2">Description</h3>
<p className="text-muted-foreground whitespace-pre-wrap">
{selectedVideo.content}
</p>
</div>
)}
{selectedVideo.hashtags.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{selectedVideo.hashtags.map((tag) => (
<Badge key={tag} variant="outline">
<Hash className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
)}
{selectedVideo.segments.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-2">Chapters</h3>
<div className="space-y-2">
{selectedVideo.segments.map((segment, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
{segment.start} - {segment.end}
</span>
<span>{segment.title}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</MainLayout>
);
};
export default Videos;

View File

@ -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))',