diff --git a/src/components/feed/MinimalPictureCard.tsx b/src/components/feed/MinimalPictureCard.tsx index e67d148..2451d1c 100644 --- a/src/components/feed/MinimalPictureCard.tsx +++ b/src/components/feed/MinimalPictureCard.tsx @@ -1,5 +1,6 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { AspectRatio } from '@/components/ui/aspect-ratio'; +import { nip19 } from 'nostr-tools'; interface MinimalPictureCardProps { event: NostrEvent; @@ -45,17 +46,21 @@ export function MinimalPictureCard({ event }: MinimalPictureCardProps) { // Use the first image only const firstImage = images[0]; + const noteId = nip19.noteEncode(event.id); return ( -
+ {firstImage.alt -
+ ); } diff --git a/src/components/feed/PictureCard.tsx b/src/components/feed/PictureCard.tsx index eea77c8..fa935a5 100644 --- a/src/components/feed/PictureCard.tsx +++ b/src/components/feed/PictureCard.tsx @@ -67,6 +67,7 @@ export function PictureCard({ event }: PictureCardProps) { const title = event.tags.find(([name]) => name === 'title')?.[1] || ''; const images = parseImetaTags(event); const hashtags = event.tags.filter(([name]) => name === 't').map(([, tag]) => tag); + const noteId = nip19.noteEncode(event.id); const nextImage = () => { setCurrentImageIndex((prev) => (prev + 1) % images.length); @@ -81,6 +82,10 @@ export function PictureCard({ event }: PictureCardProps) { navigate(`/${npub}`); }; + const goToDetails = () => { + navigate(`/${noteId}`); + }; + if (images.length === 0) { return null; } @@ -109,12 +114,12 @@ export function PictureCard({ event }: PictureCardProps) { {/* Image carousel */} -
+
{images[currentImageIndex].alt @@ -126,7 +131,10 @@ export function PictureCard({ event }: PictureCardProps) { variant="ghost" size="icon" className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full" - onClick={prevImage} + onClick={(e) => { + e.stopPropagation(); + prevImage(); + }} aria-label="Previous image" > @@ -135,7 +143,10 @@ export function PictureCard({ event }: PictureCardProps) { variant="ghost" size="icon" className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full" - onClick={nextImage} + onClick={(e) => { + e.stopPropagation(); + nextImage(); + }} aria-label="Next image" > diff --git a/src/hooks/usePictureEvent.ts b/src/hooks/usePictureEvent.ts new file mode 100644 index 0000000..d1811a9 --- /dev/null +++ b/src/hooks/usePictureEvent.ts @@ -0,0 +1,30 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Hook for fetching a single kind 20 picture event by ID + */ +export function usePictureEvent(eventId: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['picture-event', eventId], + queryFn: async ({ signal }) => { + if (!eventId) return null; + + const events = await nostr.query([{ ids: [eventId] }], { + signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) + }); + + // Validate that it's a kind 20 event + const event = events[0]; + if (event && event.kind === 20) { + return event as NostrEvent; + } + + return null; + }, + enabled: !!eventId, + }); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 6cbd0fb..d58e5ad 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -2,6 +2,7 @@ import { nip19 } from 'nostr-tools'; import { useParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { ProfileView } from './ProfileView'; +import { PictureDetails } from './PictureDetails'; import NotFound from './NotFound'; export function NIP19Page() { @@ -34,25 +35,21 @@ export function NIP19Page() { } case 'note': - // AI agent should implement note view here + // Handle note1 identifiers (hex event IDs) return (
-
- Note placeholder -
+
); case 'nevent': - // AI agent should implement event view here + // Handle nevent1 identifiers (event with metadata) return (
-
- Event placeholder -
+
); diff --git a/src/pages/PictureDetails.tsx b/src/pages/PictureDetails.tsx new file mode 100644 index 0000000..2e8a1d4 --- /dev/null +++ b/src/pages/PictureDetails.tsx @@ -0,0 +1,254 @@ +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { useAuthor } from '@/hooks/useAuthor'; +import { usePictureEvent } from '@/hooks/usePictureEvent'; +import { genUserName } from '@/lib/genUserName'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { ReactionButton } from '@/components/ReactionButton'; +import { ZapButton } from '@/components/ZapButton'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CommentsSection } from '@/components/comments/CommentsSection'; +import { NoteContent } from '@/components/NoteContent'; + +interface PictureDetailsProps { + eventId: string; +} + +interface ImageMeta { + url: string; + alt?: string; + blurhash?: string; + dim?: string; + m?: string; +} + +function parseImetaTags(event: NostrEvent): ImageMeta[] { + const images: ImageMeta[] = []; + + for (const tag of event.tags) { + if (tag[0] === 'imeta') { + const imageMeta: ImageMeta = { url: '' }; + + for (let i = 1; i < tag.length; i++) { + const part = tag[i]; + if (part.startsWith('url ')) { + imageMeta.url = part.substring(4); + } else if (part.startsWith('alt ')) { + imageMeta.alt = part.substring(4); + } else if (part.startsWith('blurhash ')) { + imageMeta.blurhash = part.substring(9); + } else if (part.startsWith('dim ')) { + imageMeta.dim = part.substring(4); + } else if (part.startsWith('m ')) { + imageMeta.m = part.substring(2); + } + } + + if (imageMeta.url) { + images.push(imageMeta); + } + } + } + + return images; +} + +function PictureDetailsContent({ event }: { event: NostrEvent }) { + const author = useAuthor(event.pubkey); + const metadata: NostrMetadata | undefined = author.data?.metadata; + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const navigate = useNavigate(); + + const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); + const profileImage = metadata?.picture; + const title = event.tags.find(([name]) => name === 'title')?.[1] || ''; + const images = parseImetaTags(event); + const hashtags = event.tags.filter(([name]) => name === 't').map(([, tag]) => tag); + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % images.length); + }; + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length); + }; + + const goToProfile = () => { + const npub = nip19.npubEncode(event.pubkey); + navigate(`/${npub}`); + }; + + if (images.length === 0) { + return ( + + +

No images found in this event.

+
+
+ ); + } + + return ( +
+ + +
+ + + {displayName[0]?.toUpperCase()} + +
+ + {displayName} + + + {new Date(event.created_at * 1000).toLocaleString()} + +
+
+
+ + + {/* Image carousel */} +
+
+ {images[currentImageIndex].alt +
+ + {/* Navigation buttons for multiple images */} + {images.length > 1 && ( + <> + + + + {/* Image indicators */} +
+ {images.map((_, index) => ( +
+ + )} +
+ + {/* Content section */} +
+ {title && ( +

{title}

+ )} + + {event.content && ( +
+ +
+ )} + + {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* Interaction buttons */} +
+ + +
+
+
+
+ + {/* Comments section */} + +
+ ); +} + +export function PictureDetails({ eventId }: PictureDetailsProps) { + const { data: event, isLoading } = usePictureEvent(eventId); + + if (isLoading) { + return ( +
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+ + + +
+
+
+
+
+ ); + } + + if (!event) { + return ( + + +

Picture not found.

+
+
+ ); + } + + return ; +}