Feature: Add Tag Page (#101)

* feat: Add TagPage and TagCard components for displaying trending and followed tags

* refactor: Adjust layout and styling in TagPage component for improved UI

* feat: Add Tag link to BottomBar for improved navigation

* refactor: Conditionally render 'My Tags' tab and adjust layout in TagPage; comment out unused pubkey check in BottomBar

* refactor: Replace TagIcon with HashIcon in BottomBar component for improved icon representation

* refactor: Remove document title update in TagPage component

* feat: Implement loading state and pagination in TagFeed and TagQuickViewFeed components

* fix: Update QuickViewKind20NoteCard to enable link to note in TagQuickViewFeed component

* fix: Increase limit for global events in TagPage to improve trending tags retrieval

* fix: Update dependency array in useEffect to track full trendingTags array for accurate updates

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-05-06 18:42:00 +02:00
committed by GitHub
parent 120f166a45
commit f54462a0d3
5 changed files with 253 additions and 19 deletions

129
app/tag/page.tsx Normal file
View File

@@ -0,0 +1,129 @@
'use client';
import Head from "next/head";
import { useNostrEvents } from "nostr-react";
import { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import TagCard from "@/components/TagCard";
export default function TagPage() {
const [trendingTags, setTrendingTags] = useState<string[]>([]);
let pubkey = '';
if (typeof window !== 'undefined') {
pubkey = window.localStorage.getItem("pubkey") ?? '';
}
const { events: followEvents, isLoading: isFollowLoading } = useNostrEvents({
filter: {
kinds: [3],
limit: 1,
authors: [pubkey],
},
});
const { events: globalEvents, isLoading: isGlobalLoading } = useNostrEvents({
filter: {
kinds: [20],
limit: 100,
},
});
// Extract tags from followed users
const followedTags = followEvents
.flatMap(event => event.tags)
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
// Get unique followed tags
const uniqueFollowedTags = Array.from(new Set(followedTags));
// Extract tags from global feed for trending tags
useEffect(() => {
if (globalEvents.length > 0) {
const allTags = globalEvents
.flatMap(event => event.tags)
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
// Count tag occurrences to find trending tags
const tagCounts = allTags.reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Sort by frequency and get top 20
const sortedTags = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag)
.slice(0, 20);
// Only update state if the tags have actually changed
if (JSON.stringify(sortedTags) !== JSON.stringify(trendingTags)) {
setTrendingTags(sortedTags);
}
}
}, [globalEvents, trendingTags]); // Depend on the full trendingTags array to capture content and order changes
return (
<>
<Head>
<title>LUMINA.rocks - Tags</title>
<meta name="description" content="Explore tags on LUMINA" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="px-2 md:px-6">
<Tabs defaultValue="trending" className="mt-4">
<TabsList className="mb-4 w-full grid grid-cols-1">
<TabsTrigger value="trending">Trending Tags</TabsTrigger>
{/* {pubkey && <TabsTrigger value="followed">My Tags</TabsTrigger>} */}
</TabsList>
<TabsContent value="trending">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{isGlobalLoading ? (
Array(8).fill(0).map((_, i) => (
<div key={i}>
<Skeleton className="h-[110px] rounded-xl" />
</div>
))
) : trendingTags.length > 0 ? (
trendingTags.map(tag => (
<TagCard key={tag} tag={tag} />
))
) : (
<div className="col-span-full text-center py-10">
<p className="text-muted-foreground">No trending tags found</p>
</div>
)}
</div>
</TabsContent>
{pubkey && (
<TabsContent value="followed">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{isFollowLoading ? (
Array(4).fill(0).map((_, i) => (
<div key={i}>
<Skeleton className="h-[110px] rounded-xl" />
</div>
))
) : uniqueFollowedTags.length > 0 ? (
uniqueFollowedTags.map(tag => (
<TagCard key={tag} tag={tag} />
))
) : (
<div className="col-span-full text-center py-10">
<p className="text-muted-foreground">No followed tags found. Follow tags to see them here.</p>
</div>
)}
</div>
</TabsContent>
)}
</Tabs>
</div>
</>
);
}

View File

@@ -4,7 +4,7 @@ import { BellIcon, GlobeIcon, HomeIcon, RowsIcon, UploadIcon } from "@radix-ui/r
import Link from "next/link"
import { FormEvent, JSX, SVGProps, useEffect, useState } from "react"
import { useRouter, usePathname } from 'next/navigation'
import { SearchIcon } from "lucide-react";
import { HashIcon, SearchIcon, TagIcon } from "lucide-react";
export default function BottomBar() {
const router = useRouter();
@@ -61,6 +61,13 @@ export default function BottomBar() {
<span className="sr-only">Upload</span>
</Link>
)}
{/* {pubkey && ( */}
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/tag', pathname)}`} href="/tag">
{/* <TagIcon className={`h-6 w-6`} /> */}
<HashIcon className={`h-6 w-6`} />
<span className="sr-only">Tags</span>
</Link>
{/* )} */}
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/search', pathname)}`} href="/search">
<SearchIcon className={`h-6 w-6`} />
<span className="sr-only">Search</span>

30
components/TagCard.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Hash } from "lucide-react";
interface TagCardProps {
tag: string;
}
const TagCard: React.FC<TagCardProps> = ({ tag }) => {
return (
<Link href={`/tag/${tag}`}>
<Card className="hover:bg-accent transition-colors h-full">
<CardHeader className="pb-2">
<CardTitle className="flex items-center text-xl">
<Hash className="h-5 w-5 mr-2" />
{tag}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
View content tagged with #{tag}
</p>
</CardContent>
</Card>
</Link>
);
};
export default TagCard;

View File

@@ -1,7 +1,9 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { useNostrEvents } from "nostr-react";
import KIND20Card from "./KIND20Card";
import { getImageUrl } from "@/utils/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
interface TagFeedProps {
tag: string;
@@ -9,27 +11,59 @@ interface TagFeedProps {
const TagFeed: React.FC<TagFeedProps> = ({ tag }) => {
const now = useRef(new Date()); // Make sure current time isn't re-rendered
const [limit, setLimit] = useState(25);
const { events } = useNostrEvents({
const { events, isLoading } = useNostrEvents({
filter: {
// since: dateToUnix(now.current), // all new events from now
// since: 0,
limit: 100,
limit: limit,
kinds: [20],
"#t": [tag],
},
});
const loadMore = () => {
setLimit(prevLimit => prevLimit + 25);
};
return (
<>
<div className="grid lg:grid-cols-3 gap-2">
{events.map((event) => (
// <p key={event.id}>{event.pubkey} posted: {event.content}</p>
<div key={event.id}>
<KIND20Card key={event.id} pubkey={event.pubkey} text={event.content} image={getImageUrl(event.tags)} eventId={event.id} tags={event.tags} event={event} showViewNoteCardButton={true} />
</div>
))}
{events.length === 0 && isLoading ? (
<>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
</>
) : (
events.map((event) => (
<div key={event.id}>
<KIND20Card key={event.id} pubkey={event.pubkey} text={event.content} image={getImageUrl(event.tags)} eventId={event.id} tags={event.tags} event={event} showViewNoteCardButton={true} />
</div>
))
)}
</div>
{!isLoading && (
<div className="flex justify-center p-4">
<Button className="w-full md:w-auto" onClick={loadMore}>Load More</Button>
</div>
)}
</>
);
}

View File

@@ -1,7 +1,9 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { useNostrEvents } from "nostr-react";
import { getImageUrl } from "@/utils/utils";
import QuickViewKind20NoteCard from "./QuickViewKind20NoteCard";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
interface TagQuickViewFeedProps {
tag: string;
@@ -9,27 +11,59 @@ interface TagQuickViewFeedProps {
const TagQuickViewFeed: React.FC<TagQuickViewFeedProps> = ({ tag }) => {
const now = useRef(new Date()); // Make sure current time isn't re-rendered
const [limit, setLimit] = useState(25);
const { events } = useNostrEvents({
const { events, isLoading } = useNostrEvents({
filter: {
// since: dateToUnix(now.current), // all new events from now
// since: 0,
limit: 100,
limit: limit,
kinds: [20],
"#t": [tag],
},
});
const loadMore = () => {
setLimit(prevLimit => prevLimit + 25);
};
return (
<>
<div className="grid grid-cols-3 gap-2">
{events.map((event) => (
// <p key={event.id}>{event.pubkey} posted: {event.content}</p>
<div key={event.id}>
<QuickViewKind20NoteCard pubkey={event.pubkey} text={event.content} image={getImageUrl(event.tags)} eventId={event.id} tags={event.tags} event={event} linkToNote={false} />
</div>
))}
{events.length === 0 && isLoading ? (
<>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
<div className="aspect-square w-full">
<Skeleton className="h-full w-full rounded-xl" />
</div>
</>
) : (
events.map((event) => (
<div key={event.id}>
<QuickViewKind20NoteCard pubkey={event.pubkey} text={event.content} image={getImageUrl(event.tags)} eventId={event.id} tags={event.tags} event={event} linkToNote={true} />
</div>
))
)}
</div>
{!isLoading && (
<div className="flex justify-center p-4">
<Button className="w-full md:w-auto" onClick={loadMore}>Load More</Button>
</div>
)}
</>
);
}