mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 17:41:10 +02:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
13
src/lib/deduplicateEvents.ts
Normal file
13
src/lib/deduplicateEvents.ts
Normal 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
144
src/pages/TagPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user