Add NoteCard component and integrate user notes into ProfileView with tabbed navigation

This commit is contained in:
2025-11-22 01:45:17 +01:00
parent 909f0432ca
commit 43e891dcb9
3 changed files with 324 additions and 53 deletions

View File

@@ -0,0 +1,106 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ReactionButton } from '@/components/ReactionButton';
import { ZapButton } from '@/components/ZapButton';
import { NoteContent } from '@/components/NoteContent';
interface NoteCardProps {
event: NostrEvent;
}
export function NoteCard({ event }: NoteCardProps) {
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const navigate = useNavigate();
const displayName = metadata?.name ?? genUserName(event.pubkey);
const profileImage = metadata?.picture;
const npub = nip19.npubEncode(event.pubkey);
const nevent = nip19.neventEncode({ id: event.id, author: event.pubkey });
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest('button') ||
target.closest('a') ||
target.tagName === 'BUTTON' ||
target.tagName === 'A'
) {
return;
}
navigate(`/${nevent}`);
};
return (
<Card
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={handleCardClick}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar
className="h-10 w-10 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
navigate(`/${npub}`);
}}
>
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/${npub}`);
}}
className="font-semibold hover:underline text-left"
>
{displayName}
</button>
<p className="text-sm text-muted-foreground">
{formatTimestamp(event.created_at)}
</p>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pb-2 space-y-4">
<NoteContent event={event} className="text-sm" />
<hr />
<div className="flex items-center gap-2 pb-2">
<ReactionButton target={event} buttonVariant='ghost'/>
<ZapButton target={event} buttonVariant='ghost'/>
</div>
</CardContent>
</Card>
);
}

32
src/hooks/useUserNotes.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook for fetching a specific user's kind 1 (NIP-10) text notes with infinite scroll
*/
export function useUserNotes(pubkey: string) {
const { nostr } = useNostr();
return useInfiniteQuery({
queryKey: ['user-notes', pubkey],
queryFn: async ({ pageParam, signal }) => {
const filter = pageParam
? { kinds: [1], authors: [pubkey], limit: 20, until: pageParam as number }
: { kinds: [1], authors: [pubkey], 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,
enabled: !!pubkey,
});
}

View File

@@ -1,23 +1,28 @@
import { useAuthor } from '@/hooks/useAuthor';
import { useUserPictures } from '@/hooks/useUserPictures';
import { useUserNotes } from '@/hooks/useUserNotes';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Loader2 } from 'lucide-react';
import { Loader2, Grid3x3, LayoutGrid, FileText } from 'lucide-react';
import { ProfileHeader } from '@/components/profile/ProfileHeader';
import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard';
import { PictureCard } from '@/components/feed/PictureCard';
import { NoteCard } from '@/components/feed/NoteCard';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
export function ProfileView({ pubkey }: { pubkey: string }) {
const [activeTab, setActiveTab] = useState('minimal');
const author = useAuthor(pubkey);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useUserPictures(pubkey);
const minimalPictures = useUserPictures(pubkey);
const fullPictures = useUserPictures(pubkey);
const notes = useUserNotes(pubkey);
const pictures = data?.pages.flat() || [];
const minimalPicturesData = minimalPictures.data?.pages.flat() || [];
const fullPicturesData = fullPictures.data?.pages.flat() || [];
const notesData = notes.data?.pages.flat() || [];
return (
<div className="container py-8">
@@ -29,54 +34,182 @@ export function ProfileView({ pubkey }: { pubkey: string }) {
isLoading={author.isLoading}
/>
{/* Pictures Section */}
<div>
<h2 className="text-2xl font-bold mb-4">Pictures</h2>
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : pictures.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No pictures found for this user.
</p>
</CardContent>
</Card>
) : (
<>
{/* Tabbed Content */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="minimal" className="flex items-center justify-center">
<Grid3x3 className="h-5 w-5" />
</TabsTrigger>
<TabsTrigger value="full" className="flex items-center justify-center">
<LayoutGrid className="h-5 w-5" />
</TabsTrigger>
<TabsTrigger value="notes" className="flex items-center justify-center">
<FileText className="h-5 w-5" />
</TabsTrigger>
</TabsList>
{/* Minimal Pictures Tab */}
<TabsContent value="minimal" className="mt-6">
{minimalPictures.isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{pictures.map((event) => (
<MinimalPictureCard key={event.id} event={event} />
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
{hasNextPage && (
<div className="flex justify-center mt-8">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="lg"
>
{isFetchingNextPage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
) : minimalPicturesData.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No pictures found for this user.
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{minimalPicturesData.map((event) => (
<MinimalPictureCard key={event.id} event={event} />
))}
</div>
)}
</>
)}
</div>
{minimalPictures.hasNextPage && (
<div className="flex justify-center mt-8">
<Button
onClick={() => minimalPictures.fetchNextPage()}
disabled={minimalPictures.isFetchingNextPage}
variant="outline"
size="lg"
>
{minimalPictures.isFetchingNextPage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</>
)}
</TabsContent>
{/* Full Pictures Tab */}
<TabsContent value="full" className="mt-6">
{fullPictures.isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<Skeleton className="aspect-square rounded-t-lg" />
<div className="p-4 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</Card>
))}
</div>
) : fullPicturesData.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No pictures found for this user.
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{fullPicturesData.map((event) => (
<PictureCard key={event.id} event={event} />
))}
</div>
{fullPictures.hasNextPage && (
<div className="flex justify-center mt-8">
<Button
onClick={() => fullPictures.fetchNextPage()}
disabled={fullPictures.isFetchingNextPage}
variant="outline"
size="lg"
>
{fullPictures.isFetchingNextPage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</>
)}
</TabsContent>
{/* Notes Tab */}
<TabsContent value="notes" className="mt-6">
{notes.isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</Card>
))}
</div>
) : notesData.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No notes found for this user.
</p>
</CardContent>
</Card>
) : (
<>
<div className="space-y-4">
{notesData.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
{notes.hasNextPage && (
<div className="flex justify-center mt-8">
<Button
onClick={() => notes.fetchNextPage()}
disabled={notes.isFetchingNextPage}
variant="outline"
size="lg"
>
{notes.isFetchingNextPage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</div>
);