mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 01:31:13 +02:00
Add AdaptivePictureCard component and integrate it into various pages; implement hashtag feed and popular hashtags hooks
This commit is contained in:
@@ -5,6 +5,7 @@ import Index from "./pages/Index";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import { Upload } from "./pages/Upload";
|
||||
import { Hashtags } from "./pages/Hashtags";
|
||||
import { TagPage } from "./pages/TagPage";
|
||||
import { SearchPage } from "./pages/Search";
|
||||
import { Notifications } from "./pages/Notifications";
|
||||
import Messages from "./pages/Messages";
|
||||
@@ -19,6 +20,7 @@ export function AppRouter() {
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/upload" element={<Upload />} />
|
||||
<Route path="/hashtags" element={<Hashtags />} />
|
||||
<Route path="/tag/:tag" element={<TagPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
|
||||
25
src/components/feed/AdaptivePictureCard.tsx
Normal file
25
src/components/feed/AdaptivePictureCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { PictureCard } from './PictureCard';
|
||||
import { MinimalPictureCard } from './MinimalPictureCard';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
interface AdaptivePictureCardProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* A smart component that renders either PictureCard or MinimalPictureCard
|
||||
* based on the user's preferMinimalCards setting in AppContext.
|
||||
*
|
||||
* This allows for consistent card rendering across the app without
|
||||
* needing to manually check the preference in every component.
|
||||
*/
|
||||
export function AdaptivePictureCard({ event }: AdaptivePictureCardProps) {
|
||||
const { config } = useAppContext();
|
||||
|
||||
if (config.preferMinimalCards) {
|
||||
return <MinimalPictureCard event={event} />;
|
||||
}
|
||||
|
||||
return <PictureCard event={event} />;
|
||||
}
|
||||
33
src/hooks/useHashtagFeed.ts
Normal file
33
src/hooks/useHashtagFeed.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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 filtered by hashtag with infinite scroll
|
||||
*/
|
||||
export function useHashtagFeed(hashtag: string) {
|
||||
const { nostr } = useNostr();
|
||||
const normalizedTag = hashtag.toLowerCase();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['hashtag-feed', normalizedTag],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
const filter = pageParam
|
||||
? { kinds: [20], '#t': [normalizedTag], limit: 20, until: pageParam as number }
|
||||
: { kinds: [20], '#t': [normalizedTag], 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: !!hashtag,
|
||||
});
|
||||
}
|
||||
50
src/hooks/usePopularHashtags.ts
Normal file
50
src/hooks/usePopularHashtags.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface HashtagCount {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching popular hashtags from kind 20 (NIP-68) picture events
|
||||
* Returns hashtags sorted by frequency
|
||||
*/
|
||||
export function usePopularHashtags(limit: number = 50) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['popular-hashtags', limit],
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
// Query recent kind 20 events to extract hashtags
|
||||
const events = await nostr.query([{ kinds: [20], limit: 500 }], { signal });
|
||||
|
||||
// Count hashtag occurrences
|
||||
const hashtagMap = new Map<string, number>();
|
||||
|
||||
events.forEach((event: NostrEvent) => {
|
||||
// Extract 't' tags (hashtags)
|
||||
const hashtags = event.tags.filter(([name]) => name === 't');
|
||||
|
||||
hashtags.forEach(([_, tag]) => {
|
||||
if (tag) {
|
||||
const normalizedTag = tag.toLowerCase();
|
||||
hashtagMap.set(normalizedTag, (hashtagMap.get(normalizedTag) || 0) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by count
|
||||
const sortedHashtags: HashtagCount[] = Array.from(hashtagMap.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
|
||||
return sortedHashtags;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,89 @@
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { usePopularHashtags } from '@/hooks/usePopularHashtags';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Hash } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function Hashtags() {
|
||||
const { data: hashtags, isLoading, isError } = usePopularHashtags(9);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">Hashtags</h1>
|
||||
<p className="text-muted-foreground">Hashtags page - coming soon</p>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Popular Hashtags</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Discover trending topics and explore content by hashtag
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
Failed to load hashtags. Try checking your relay connections or wait a moment.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && hashtags && hashtags.length === 0 && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<Hash className="w-12 h-12 mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No hashtags found yet. Be the first to add hashtags to your pictures!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && hashtags && hashtags.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{hashtags.map(({ tag, count }) => (
|
||||
<Card
|
||||
key={tag}
|
||||
className="cursor-pointer hover:shadow-lg transition-all hover:scale-105"
|
||||
onClick={() => navigate(`/tag/${encodeURIComponent(tag)}`)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Hash className="w-5 h-5 text-primary" />
|
||||
<span className="truncate">{tag}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{count} {count === 1 ? 'post' : 'posts'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -2,13 +2,11 @@ import { useSeoMeta } from '@unhead/react';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { usePictureFeed } from '@/hooks/usePictureFeed';
|
||||
import { useFollowerPictureFeed } from '@/hooks/useFollowerPictureFeed';
|
||||
import { PictureCard } from '@/components/feed/PictureCard';
|
||||
import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard';
|
||||
import { AdaptivePictureCard } from '@/components/feed/AdaptivePictureCard';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
const Index = () => {
|
||||
@@ -17,7 +15,6 @@ const Index = () => {
|
||||
description: 'Discover amazing pictures shared on Nostr.',
|
||||
});
|
||||
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Use follower feed for logged-in users, global feed for logged-out users
|
||||
@@ -83,11 +80,7 @@ const Index = () => {
|
||||
// Picture feed
|
||||
<>
|
||||
{pictures.map((picture) => (
|
||||
config.preferMinimalCards ? (
|
||||
<MinimalPictureCard key={picture.id} event={picture} />
|
||||
) : (
|
||||
<PictureCard key={picture.id} event={picture} />
|
||||
)
|
||||
<AdaptivePictureCard key={picture.id} event={picture} />
|
||||
))}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
|
||||
@@ -4,24 +4,21 @@ 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, Grid3x3, LayoutGrid, FileText } from 'lucide-react';
|
||||
import { Loader2, Image, FileText } from 'lucide-react';
|
||||
import { ProfileHeader } from '@/components/profile/ProfileHeader';
|
||||
import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard';
|
||||
import { PictureCard } from '@/components/feed/PictureCard';
|
||||
import { AdaptivePictureCard } from '@/components/feed/AdaptivePictureCard';
|
||||
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 [activeTab, setActiveTab] = useState('pictures');
|
||||
const author = useAuthor(pubkey);
|
||||
|
||||
const minimalPictures = useUserPictures(pubkey);
|
||||
const fullPictures = useUserPictures(pubkey);
|
||||
const pictures = useUserPictures(pubkey);
|
||||
const notes = useUserNotes(pubkey);
|
||||
|
||||
const minimalPicturesData = minimalPictures.data?.pages.flat() || [];
|
||||
const fullPicturesData = fullPictures.data?.pages.flat() || [];
|
||||
const picturesData = pictures.data?.pages.flat() || [];
|
||||
const notesData = notes.data?.pages.flat() || [];
|
||||
|
||||
return (
|
||||
@@ -36,68 +33,20 @@ export function ProfileView({ pubkey }: { pubkey: string }) {
|
||||
|
||||
{/* 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" />
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="pictures" className="flex items-center justify-center gap-2">
|
||||
<Image className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">Pictures</span>
|
||||
</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">
|
||||
<TabsTrigger value="notes" className="flex items-center justify-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">Notes</span>
|
||||
</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">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
{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 ? (
|
||||
{/* Pictures Tab */}
|
||||
<TabsContent value="pictures" className="mt-6">
|
||||
{pictures.isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
@@ -109,7 +58,7 @@ export function ProfileView({ pubkey }: { pubkey: string }) {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : fullPicturesData.length === 0 ? (
|
||||
) : picturesData.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
@@ -120,20 +69,20 @@ export function ProfileView({ pubkey }: { pubkey: string }) {
|
||||
) : (
|
||||
<>
|
||||
<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} />
|
||||
{picturesData.map((event) => (
|
||||
<AdaptivePictureCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fullPictures.hasNextPage && (
|
||||
{pictures.hasNextPage && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
onClick={() => fullPictures.fetchNextPage()}
|
||||
disabled={fullPictures.isFetchingNextPage}
|
||||
onClick={() => pictures.fetchNextPage()}
|
||||
disabled={pictures.isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{fullPictures.isFetchingNextPage ? (
|
||||
{pictures.isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
|
||||
157
src/pages/TagPage.tsx
Normal file
157
src/pages/TagPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useHashtagFeed } from '@/hooks/useHashtagFeed';
|
||||
import { AdaptivePictureCard } from '@/components/feed/AdaptivePictureCard';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Hash, ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export function TagPage() {
|
||||
const { tag } = useParams<{ tag: string }>();
|
||||
const navigate = useNavigate();
|
||||
const decodedTag = tag ? decodeURIComponent(tag) : '';
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useHashtagFeed(decodedTag);
|
||||
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleObserver = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const [target] = entries;
|
||||
if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[fetchNextPage, hasNextPage, isFetchingNextPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = observerTarget.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(handleObserver, {
|
||||
threshold: 0.1,
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [handleObserver]);
|
||||
|
||||
const pictures = data?.pages.flat() ?? [];
|
||||
|
||||
if (!tag) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container py-8">
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<p className="text-muted-foreground">Invalid hashtag</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-4"
|
||||
onClick={() => navigate('/hashtags')}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Hashtags
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Hash className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold">{decodedTag}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Browse all pictures tagged with #{decodedTag}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
Failed to load pictures. Try checking your relay connections or wait a moment.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pictures.length === 0 && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<Hash className="w-12 h-12 mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No pictures found with #{decodedTag}. Be the first to post one!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pictures.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pictures.map((picture) => (
|
||||
<AdaptivePictureCard key={picture.id} event={picture} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<div ref={observerTarget} className="h-10 mt-8">
|
||||
{isFetchingNextPage && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user