@@ -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 (
);
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]({images[currentImageIndex].url})
+
+
+ {/* 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
;
+}