Add AdaptivePictureCard component and integrate it into various pages; implement hashtag feed and popular hashtags hooks

This commit is contained in:
2025-11-27 22:06:21 +01:00
parent 77afd0d108
commit 7089275b8c
8 changed files with 370 additions and 84 deletions

View File

@@ -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 />} />

View 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} />;
}

View 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,
});
}

View 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
});
}

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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
View 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>
);
}