Add PictureCard component and usePictureFeed hook; update Index page for picture feed display

This commit is contained in:
2025-11-22 00:51:54 +01:00
parent c915cac046
commit a0c1391059
3 changed files with 298 additions and 9 deletions

View File

@@ -0,0 +1,171 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { AspectRatio } from '@/components/ui/aspect-ratio';
import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface PictureCardProps {
event: NostrEvent;
}
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;
}
export function PictureCard({ event }: PictureCardProps) {
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const [currentImageIndex, setCurrentImageIndex] = useState(0);
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);
};
if (images.length === 0) {
return null;
}
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">{displayName}</CardTitle>
<CardDescription className="text-xs">
{new Date(event.created_at * 1000).toLocaleDateString()}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Image carousel */}
<div className="relative">
<AspectRatio ratio={1}>
<img
src={images[currentImageIndex].url}
alt={images[currentImageIndex].alt || title || 'Picture'}
className="object-cover w-full h-full"
loading="lazy"
/>
</AspectRatio>
{/* Navigation buttons for multiple images */}
{images.length > 1 && (
<>
<Button
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}
aria-label="Previous image"
>
<ChevronLeft className="h-6 w-6" />
</Button>
<Button
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}
aria-label="Next image"
>
<ChevronRight className="h-6 w-6" />
</Button>
{/* Image indicators */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
{images.map((_, index) => (
<div
key={index}
className={`h-1.5 rounded-full transition-all ${
index === currentImageIndex
? 'w-6 bg-white'
: 'w-1.5 bg-white/50'
}`}
/>
))}
</div>
</>
)}
</div>
{/* Content section */}
<div className="p-4 space-y-3">
{title && (
<h3 className="font-semibold text-lg leading-tight">{title}</h3>
)}
{event.content && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words">
{event.content}
</p>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
#{tag}
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,31 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook for fetching kind 20 (NIP-68) picture events with infinite scroll
*/
export function usePictureFeed() {
const { nostr } = useNostr();
return useInfiniteQuery({
queryKey: ['picture-feed'],
queryFn: async ({ pageParam, signal }) => {
const filter = pageParam
? { kinds: [20], limit: 20, until: pageParam as number }
: { kinds: [20], limit: 20 };
const events = await nostr.query([filter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(1500)])
});
return events as NostrEvent[];
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
// Subtract 1 since 'until' is inclusive
return lastPage[lastPage.length - 1].created_at - 1;
},
initialPageParam: undefined as number | undefined,
});
}

View File

@@ -1,23 +1,110 @@
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { usePictureFeed } from '@/hooks/usePictureFeed';
import { PictureCard } from '@/components/feed/PictureCard';
import { useInView } from 'react-intersection-observer';
import { useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
const Index = () => {
useSeoMeta({
title: 'Welcome to Your Blank App',
description: 'A modern Nostr client application built with React, TailwindCSS, and Nostrify.',
title: 'Picture Feed - LUMINA',
description: 'Discover amazing pictures shared on Nostr.',
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = usePictureFeed();
const { ref, inView } = useInView();
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Remove duplicate events by ID
const pictures = useMemo(() => {
const seen = new Set<string>();
return data?.pages.flat().filter(event => {
if (!event.id || seen.has(event.id)) return false;
seen.add(event.id);
return true;
}) || [];
}, [data?.pages]);
return (
<Layout>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">
Welcome to Your Blank App
</h1>
<p className="text-xl text-muted-foreground">
Start building your amazing project here!
<div className="container max-w-2xl mx-auto px-4 py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Picture Feed</h1>
<p className="text-muted-foreground">
Discover amazing pictures shared on Nostr
</p>
</div>
<div className="space-y-6">
{isLoading ? (
// Loading skeletons
Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="overflow-hidden">
<div className="p-4 flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<Skeleton className="w-full aspect-square" />
<div className="p-4 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
))
) : pictures.length === 0 ? (
// Empty state
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<p className="text-muted-foreground">
No pictures found yet. Try checking your relay connections or wait a moment for content to load.
</p>
</div>
</CardContent>
</Card>
) : (
// Picture feed
<>
{pictures.map((picture) => (
<PictureCard key={picture.id} event={picture} />
))}
{/* Infinite scroll trigger */}
{hasNextPage && (
<div ref={ref} className="py-4">
{isFetchingNextPage && (
<Card className="overflow-hidden">
<div className="p-4 flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<Skeleton className="w-full aspect-square" />
<div className="p-4 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
)}
</div>
)}
</>
)}
</div>
</div>
</Layout>
);