mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-04-08 06:26:55 +02:00
Add PictureCard component and usePictureFeed hook; update Index page for picture feed display
This commit is contained in:
171
src/components/feed/PictureCard.tsx
Normal file
171
src/components/feed/PictureCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/hooks/usePictureFeed.ts
Normal file
31
src/hooks/usePictureFeed.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user