Implement Tag page (#36)

* feat: add tag browsing functionality with dedicated TagPage and routing updates

* feat: implement infinite scrolling and deduplication for hashtag posts

* Update src/pages/TagPage.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/hooks/useBlogPostsByHashtag.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Extract duplicated event deduplication logic into shared utility (#41)

* Initial plan

* refactor: extract deduplication logic into reusable utility function

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
mroxso
2025-11-22 22:23:05 +01:00
committed by GitHub
parent d892f48896
commit e7035da411
8 changed files with 209 additions and 20 deletions

View File

@@ -10,6 +10,7 @@ import Nip05ProfilePage from "./pages/Nip05ProfilePage";
import ArticleByDTagPage from "./pages/ArticleByDTagPage";
import { NIP19Page } from "./pages/NIP19Page";
import SettingsPage from "./pages/SettingsPage";
import TagPage from "./pages/TagPage";
import NotFound from "./pages/NotFound";
import HomePage from "./pages/HomePage";
@@ -30,6 +31,8 @@ export function AppRouter() {
<Route path="/p/:nip05" element={<Nip05ProfilePage />} />
{/* Article by d-tag route (e.g., /article/my-article-slug) */}
<Route path="/article/:dtag" element={<ArticleByDTagPage />} />
{/* Tag browsing route (e.g., /tag/bitcoin) */}
<Route path="/tag/:tag" element={<TagPage />} />
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View File

@@ -182,7 +182,7 @@ export function ArticleView({ post }: ArticleViewProps) {
{hashtags.map((tag) => (
<Link
key={tag}
to={`/search?q=%23${encodeURIComponent(tag)}`}
to={`/tag/${encodeURIComponent(tag)}`}
className="hover:opacity-80"
>
<Badge variant="secondary">#{tag}</Badge>

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -5,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Hash, ChevronRight } from 'lucide-react';
import { useBlogPostsByHashtag } from '@/hooks/useBlogPostsByHashtag';
import { ArticlePreview } from '@/components/ArticlePreview';
import { deduplicateEvents } from '@/lib/deduplicateEvents';
interface LatestInHashtagProps {
hashtag: string;
@@ -16,7 +18,12 @@ const INITIAL_POSTS_COUNT = 3;
export function LatestInHashtag({ hashtag, icon, title }: LatestInHashtagProps) {
const navigate = useNavigate();
const { data: posts, isLoading } = useBlogPostsByHashtag(hashtag, 4);
const { data, isLoading } = useBlogPostsByHashtag(hashtag, 4);
// Remove duplicate events by ID
const posts = useMemo(() => {
return deduplicateEvents(data?.pages.flat() || []);
}, [data?.pages]);
// Loading state
if (isLoading) {
@@ -67,7 +74,7 @@ export function LatestInHashtag({ hashtag, icon, title }: LatestInHashtagProps)
</div>
{hasMore && (
<Button
onClick={() => navigate(`/search?q=${encodeURIComponent('#' + hashtag)}`)}
onClick={() => navigate(`/tag/${encodeURIComponent(hashtag)}`)}
variant="outline"
size="default"
className="gap-1"

View File

@@ -104,11 +104,13 @@ export function SearchBar({ className }: { className?: string }) {
}
break;
case 'hashtag':
// Navigate to search page with hashtag
navigate(`/search?q=${encodeURIComponent(detected.value)}`);
case 'hashtag': {
// Navigate to tag page (strip the # prefix)
const tagName = detected.value.startsWith('#') ? detected.value.slice(1) : detected.value;
navigate(`/tag/${encodeURIComponent(tagName)}`);
setSearchTerm('');
break;
}
case 'search':
default:

View File

@@ -90,7 +90,7 @@ export function TrendingTags() {
key={tag}
variant="secondary"
className="text-sm py-2 px-3 cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => navigate(`/search?q=${encodeURIComponent('#' + tag)}`)}
onClick={() => navigate(`/tag/${encodeURIComponent(tag)}`)}
>
<Hash className="h-3 w-3 mr-1" />
{tag}

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -24,23 +24,32 @@ function validateBlogPost(event: NostrEvent): event is BlogPost {
}
/**
* Hook to fetch blog posts filtered by a specific hashtag
* Hook to fetch blog posts filtered by a specific hashtag with infinite scroll
*/
export function useBlogPostsByHashtag(hashtag: string, limit: number = 50) {
export function useBlogPostsByHashtag(hashtag: string, limit: number = 20) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['blog-posts-hashtag', hashtag],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
return useInfiniteQuery({
queryKey: ['blog-posts-hashtag', hashtag, limit],
queryFn: async ({ pageParam, signal }) => {
const filter: {
kinds: number[];
'#t': string[];
limit: number;
until?: number;
} = {
kinds: [30023],
'#t': [hashtag.toLowerCase()],
limit: limit
};
if (pageParam) {
filter.until = pageParam;
}
const events = await nostr.query(
[{
kinds: [30023],
'#t': [hashtag.toLowerCase()],
limit: limit,
}],
{ signal }
[filter],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) }
);
// Filter and validate events
@@ -57,5 +66,16 @@ export function useBlogPostsByHashtag(hashtag: string, limit: number = 50) {
return bTime - aTime;
});
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
// Get the oldest timestamp from the last page
const oldestPost = lastPage[lastPage.length - 1];
const publishedAt = oldestPost.tags.find(([name]) => name === 'published_at')?.[1];
const timestamp = publishedAt ? parseInt(publishedAt) : oldestPost.created_at;
return timestamp - 1; // Subtract 1 since 'until' is inclusive
},
initialPageParam: undefined,
});
}

View File

@@ -0,0 +1,13 @@
/**
* Deduplicates an array of events by their ID.
* @param events - Array of events with optional id property
* @returns Deduplicated array of events
*/
export function deduplicateEvents<T extends { id?: string }>(events: T[]): T[] {
const seen = new Set<string>();
return events.filter(event => {
if (!event.id || seen.has(event.id)) return false;
seen.add(event.id);
return true;
});
}

144
src/pages/TagPage.tsx Normal file
View File

@@ -0,0 +1,144 @@
import { useParams, Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMemo, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useBlogPostsByHashtag } from '@/hooks/useBlogPostsByHashtag';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Hash, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ArticlePreview } from '@/components/ArticlePreview';
import { deduplicateEvents } from '@/lib/deduplicateEvents';
export default function TagPage() {
const { tag } = useParams<{ tag: string }>();
const hashtag = tag || '';
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useBlogPostsByHashtag(hashtag, 20);
const { ref, inView } = useInView();
// Auto-fetch next page when the sentinel element comes into view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Remove duplicate events by ID
const posts = useMemo(() => {
return deduplicateEvents(data?.pages.flat() || []);
}, [data?.pages]);
// Set SEO meta tags
const resultCount = posts?.length || 0;
const title = `#${hashtag} - Articles - zelo.news`;
const description = `Browse article${resultCount !== 1 ? 's' : ''} tagged with #${hashtag} on zelo.news`;
useSeoMeta({
title,
description,
});
if (isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Header */}
<div className="space-y-4">
<Button variant="ghost" onClick={() => window.history.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex items-center gap-3 border-b pb-4">
<Hash className="h-8 w-8 text-primary" />
<div>
<Skeleton className="h-8 w-48 mb-1" />
</div>
</div>
</div>
{/* Loading skeletons */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardContent className="pt-6">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Header with back button */}
<div className="space-y-4">
<Button variant="ghost" asChild>
<Link to="/">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Link>
</Button>
{/* Tag heading */}
<div className="flex items-center gap-3 border-b pb-4">
<Hash className="h-8 w-8 text-primary" />
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">
{hashtag}
</h1>
</div>
</div>
</div>
{/* No results */}
{!isLoading && posts.length === 0 && (
<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">
No articles found with tag #{hashtag}. Check your relay connections or wait for content to load.
</p>
</div>
</CardContent>
</Card>
)}
{/* Posts Grid */}
{posts && posts.length > 0 && (
<>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<ArticlePreview key={post.id} post={post} />
))}
</div>
{/* Infinite scroll sentinel */}
{hasNextPage && (
<div ref={ref} className="col-span-full py-8">
{isFetchingNextPage && (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardContent className="pt-6">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
);
}