mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 09:41:32 +02:00
Add PictureDetails component and related hooks for displaying picture events
This commit is contained in:
@@ -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 (
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<a
|
||||
href={`/${noteId}`}
|
||||
className="overflow-hidden rounded-lg block cursor-pointer group"
|
||||
>
|
||||
<AspectRatio ratio={1}>
|
||||
<img
|
||||
src={firstImage.url}
|
||||
alt={firstImage.alt || title || 'Picture'}
|
||||
className="object-cover w-full h-full"
|
||||
className="object-cover w-full h-full transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
<CardContent className="p-0 flex flex-col flex-1">
|
||||
{/* Image carousel */}
|
||||
<div className="relative">
|
||||
<div className="relative cursor-pointer group" onClick={goToDetails}>
|
||||
<AspectRatio ratio={1}>
|
||||
<img
|
||||
src={images[currentImageIndex].url}
|
||||
alt={images[currentImageIndex].alt || title || 'Picture'}
|
||||
className="object-cover w-full h-full"
|
||||
className="object-cover w-full h-full transition-transform group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</AspectRatio>
|
||||
@@ -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"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
@@ -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"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
|
||||
30
src/hooks/usePictureEvent.ts
Normal file
30
src/hooks/usePictureEvent.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<Layout>
|
||||
<div className="container py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
Note placeholder
|
||||
</div>
|
||||
<PictureDetails eventId={data as string} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
case 'nevent':
|
||||
// AI agent should implement event view here
|
||||
// Handle nevent1 identifiers (event with metadata)
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
Event placeholder
|
||||
</div>
|
||||
<PictureDetails eventId={(data as { id: string }).id} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
254
src/pages/PictureDetails.tsx
Normal file
254
src/pages/PictureDetails.tsx
Normal file
@@ -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 (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<p className="text-muted-foreground">No images found in this event.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12 cursor-pointer hover:opacity-80 transition-opacity" onClick={goToProfile}>
|
||||
<AvatarImage src={profileImage} alt={displayName} />
|
||||
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle
|
||||
className="text-lg truncate cursor-pointer hover:underline"
|
||||
onClick={goToProfile}
|
||||
>
|
||||
{displayName}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{new Date(event.created_at * 1000).toLocaleString()}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{/* Image carousel */}
|
||||
<div className="relative bg-black">
|
||||
<div className="max-w-full mx-auto">
|
||||
<img
|
||||
src={images[currentImageIndex].url}
|
||||
alt={images[currentImageIndex].alt || title || 'Picture'}
|
||||
className="object-contain w-full max-h-[70vh] mx-auto"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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-4 left-1/2 -translate-x-1/2 flex gap-1.5">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
index === currentImageIndex
|
||||
? 'w-8 bg-white'
|
||||
: 'w-2 bg-white/50 hover:bg-white/70'
|
||||
}`}
|
||||
aria-label={`Go to image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content section */}
|
||||
<div className="p-6 space-y-4">
|
||||
{title && (
|
||||
<h1 className="font-bold text-2xl leading-tight">{title}</h1>
|
||||
)}
|
||||
|
||||
{event.content && (
|
||||
<div className="text-base whitespace-pre-wrap break-words">
|
||||
<NoteContent event={event} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-sm">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interaction buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<ReactionButton target={event} showCount buttonVariant="ghost" />
|
||||
<ZapButton target={event} showCount buttonVariant="ghost" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comments section */}
|
||||
<CommentsSection root={event} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PictureDetails({ eventId }: PictureDetailsProps) {
|
||||
const { data: event, isLoading } = usePictureEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Skeleton className="w-full h-[70vh]" />
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<p className="text-muted-foreground">Picture not found.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <PictureDetailsContent event={event} />;
|
||||
}
|
||||
Reference in New Issue
Block a user