mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 01:31:13 +02:00
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:
129
app/tag/page.tsx
Normal file
129
app/tag/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
30
components/TagCard.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user