mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-05 02:01:47 +02:00
Add NoteCard component and integrate user notes into ProfileView with tabbed navigation
This commit is contained in:
106
src/components/feed/NoteCard.tsx
Normal file
106
src/components/feed/NoteCard.tsx
Normal 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
32
src/hooks/useUserNotes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user