Comprehensive site update with livestream functionality and Nostr improvements
- Implemented complete livestream functionality with HLS video player and real-time chat - Added NIP-17 compliant private messaging with kind 14 messages and NIP-44 encryption - Enhanced markdown rendering for blog posts with syntax highlighting and improved formatting - Added NIP-05 identity verification configuration for patrickulrich.com - Reorganized homepage layout with recent activity above projects section - Created comprehensive media sections for blog posts, photos, and videos - Improved UI components with proper TypeScript types and error handling - Added relay preferences discovery for optimized message delivery - Enhanced authentication flow with login modal integration - Updated styling and layout for better user experience 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
df80653a83
commit
241e2f7a7b
@ -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
1568
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
13
public/.well-known/nostr.json
Normal file
13
public/.well-known/nostr.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d",
|
||||
"oldaccount": "ad2e457bd523f56878091176bca09e3a824fd82c04719fa7caf7ddecb28e32e7"
|
||||
},
|
||||
"relays": {
|
||||
"0f563fe2cfdf180cb104586b95873379a0c1fdcfbc301a80c8255f33d15f039d": [
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.goodmorningbitcoin.com",
|
||||
"wss://relay.damus.io"
|
||||
]
|
||||
}
|
||||
}
|
24
public/manifest.webmanifest
Normal file
24
public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
@ -31,7 +31,7 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
theme: "light",
|
||||
theme: "dark",
|
||||
relayUrl: "wss://relay.primal.net",
|
||||
};
|
||||
|
||||
|
@ -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 */}
|
||||
|
186
src/components/MarkdownContent.tsx
Normal file
186
src/components/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
232
src/components/MessageDialog.tsx
Normal file
232
src/components/MessageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
43
src/components/layout/MainLayout.tsx
Normal file
43
src/components/layout/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
63
src/components/layout/Sidebar.tsx
Normal file
63
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
405
src/components/livestream/LiveChat.tsx
Normal file
405
src/components/livestream/LiveChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
145
src/components/livestream/LiveStreamPlayer.tsx
Normal file
145
src/components/livestream/LiveStreamPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
136
src/components/livestream/LiveStreamToolbar.tsx
Normal file
136
src/components/livestream/LiveStreamToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
131
src/hooks/useBlogPosts.ts
Normal 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
32
src/hooks/useLiveChat.ts
Normal 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
|
||||
});
|
||||
}
|
97
src/hooks/useLiveStream.ts
Normal file
97
src/hooks/useLiveStream.ts
Normal 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
139
src/hooks/usePhotos.ts
Normal 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,
|
||||
});
|
||||
}
|
55
src/hooks/useRelayPreferences.ts
Normal file
55
src/hooks/useRelayPreferences.ts
Normal 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
215
src/hooks/useVideos.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
254
src/pages/Blog.tsx
Normal 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
214
src/pages/BlogPost.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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
241
src/pages/Photos.tsx
Normal 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
390
src/pages/Videos.tsx
Normal 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;
|
@ -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))',
|
||||
|
Loading…
x
Reference in New Issue
Block a user