diff --git a/app/browse/components/BrowsePageClient.tsx b/app/browse/components/BrowsePageClient.tsx deleted file mode 100644 index f3d231ee1..000000000 --- a/app/browse/components/BrowsePageClient.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client'; - -import { Topic } from '@/types/topic'; -import { ManageTopics } from '@/components/Topic/ManageTopics'; - -interface BrowsePageClientProps { - initialTopics: Topic[]; -} - -export function BrowsePageClient({ initialTopics }: BrowsePageClientProps) { - return ; -} diff --git a/app/browse/components/BrowsePageHeader.tsx b/app/browse/components/BrowsePageHeader.tsx deleted file mode 100644 index a292da021..000000000 --- a/app/browse/components/BrowsePageHeader.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import { TopicSearch } from '@/components/Search/TopicSearch'; -import { SearchSuggestion } from '@/types/search'; -import { ButtonGroup } from '@/components/ui/ButtonGroup'; -import { MainPageHeader } from '@/components/ui/MainPageHeader'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faGrid3 as faGrid3Light } from '@fortawesome/pro-light-svg-icons'; - -interface BrowsePageHeaderProps { - activeTab: 'all' | 'following'; - onTabChange: (tab: 'all' | 'following') => void; - onSearch?: (query: string) => void; - onSearchResults?: (results: SearchSuggestion[]) => void; - onSearching?: (isSearching: boolean) => void; - followingCount?: number; - showTitle?: boolean; -} - -export function BrowsePageHeader({ - activeTab, - onTabChange, - onSearch, - onSearchResults, - onSearching, - followingCount = 0, - showTitle = true, -}: BrowsePageHeaderProps) { - return ( -
-
- {showTitle && ( -
-

Topics

- } - title="Browse" - subtitle="Discover and follow research topics" - showTitle={false} - /> -
- )} - -
-
- -
- -
- onTabChange(value as 'all' | 'following')} - options={[ - { value: 'all', label: 'All Topics' }, - { value: 'following', label: 'Following', badge: followingCount }, - ]} - /> -
-
-
-
- ); -} diff --git a/app/browse/loading.tsx b/app/browse/loading.tsx deleted file mode 100644 index 2ec5dedd8..000000000 --- a/app/browse/loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PageLayout } from '@/app/layouts/PageLayout'; -import { TopicListSkeleton } from '@/components/skeletons/TopicListSkeleton'; - -export default function BrowseLoading() { - return ( - -
- {/* BrowsePageHeader placeholder */} -
-
-
-
- - {/* Search bar placeholder */} -
- - -
- - ); -} diff --git a/app/browse/page.tsx b/app/browse/page.tsx deleted file mode 100644 index cb2142163..000000000 --- a/app/browse/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Metadata } from 'next'; -import { Topic } from '@/types/topic'; -import { HubService } from '@/services/hub.service'; -import { PageLayout } from '@/app/layouts/PageLayout'; -import { BrowsePageClient } from './components/BrowsePageClient'; -import { getBrowsePageMetadata } from '@/lib/metadata-helpers'; - -export async function generateMetadata(): Promise { - return getBrowsePageMetadata(); -} - -// This tells Next.js to statically generate this page at build time -export const revalidate = 3600; // Revalidate every hour (3600 seconds) - -async function getTopics() { - try { - const topics = await HubService.getPrimaryHubs(); - return { topics, error: null }; - } catch (error) { - console.error('Failed to fetch topics:', error); - return { topics: [], error: 'Failed to load topics' }; - } -} - -export default async function BrowsePage() { - const { topics, error } = await getTopics(); - - if (error) { - return ( - -
-
{error}
-
-
- ); - } - - return ( - - - - ); -} diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx index 63f17d8dc..c3b1f1527 100644 --- a/app/layouts/topbar/pageRoutes.tsx +++ b/app/layouts/topbar/pageRoutes.tsx @@ -3,14 +3,9 @@ import { faHouse as faHouseLight, faBookmark as faBookmarkLight, faCommentsQuestion, - faGrid3 as faGrid3Light, } from '@fortawesome/pro-light-svg-icons'; -import { Shield, Hash, Users, Activity, Settings } from 'lucide-react'; -import Image from 'next/image'; +import { Shield, Users, Activity, Settings } from 'lucide-react'; import { Icon } from '@/components/ui/icons'; -import { getTopicEmoji } from '@/components/Topic/TopicEmojis'; -import { toTitleCase } from '@/utils/stringUtils'; -import { getSourceLogo, getPreprintDisplayName } from '@/utils/preprintUtil'; import { HOME_TAB_PATHS, isHomeTabPath } from '@/hooks/useFundTabs'; export interface PageInfo { @@ -25,7 +20,6 @@ export const ROOT_NAVIGATION_PATHS = new Set([ '/peer-review', '/journal', '/notebook', - '/browse', '/lists', '/settings', '/endowment', @@ -48,13 +42,6 @@ const ROUTE_RULES: RouteRule[] = [ icon: , }), }, - { - match: (p) => p === '/browse', - getInfo: () => ({ - title: 'Browse', - icon: , - }), - }, { match: (p) => p === '/my-funding', getInfo: () => ({ @@ -196,42 +183,6 @@ const ROUTE_RULES: RouteRule[] = [ icon: , }), }, - { - match: (p) => p.startsWith('/topic/'), - getInfo: (pathname) => { - const topicSlug = pathname.split('/topic/')[1]?.split('/')[0]; - if (!topicSlug) return { title: '' }; - - const preprintLogo = getSourceLogo(topicSlug); - if (preprintLogo && preprintLogo !== 'rhJournal2') { - return { - title: '', - icon: ( - {getPreprintDisplayName(topicSlug)} - ), - }; - } - - const emoji = getTopicEmoji(topicSlug); - const topicName = toTitleCase(topicSlug.replace(/-/g, ' ')); - - return { - title: topicName, - icon: emoji ? ( - {emoji} - ) : ( - - ), - }; - }, - }, ]; export const getPageInfo = (pathname: string, searchParams?: URLSearchParams): PageInfo | null => { diff --git a/app/sitemap.ts b/app/sitemap.ts index 4ca2a525e..131c47995 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -71,7 +71,6 @@ const STATIC_ROUTES: MetadataRoute.Sitemap = [ { url: `${SITE_URL}/fund/proposals`, changeFrequency: 'daily', priority: 0.8 }, { url: `${SITE_URL}/peer-review`, changeFrequency: 'daily', priority: 0.8 }, { url: `${SITE_URL}/journal`, changeFrequency: 'daily', priority: 0.7 }, - { url: `${SITE_URL}/browse`, changeFrequency: 'daily', priority: 0.6 }, { url: `${SITE_URL}/lists`, changeFrequency: 'daily', priority: 0.5 }, { url: `${SITE_URL}/about`, changeFrequency: 'monthly', priority: 0.5 }, { url: `${SITE_URL}/researchcoin`, changeFrequency: 'monthly', priority: 0.5 }, @@ -80,7 +79,7 @@ const STATIC_ROUTES: MetadataRoute.Sitemap = [ ]; export async function generateSitemaps() { - return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]; } export default async function sitemap({ @@ -97,23 +96,12 @@ export default async function sitemap({ (item) => item.content_object ); - if (sitemapId === 2) { - const hubs = await fetchAllPages<{ slug: string }>('/api/hub/?page_size=1000'); - return dedupe( - hubs.map((hub) => ({ - url: `${SITE_URL}/topic/${hub.slug}`, - changeFrequency: 'daily' as const, - priority: 0.6, - })) - ); - } - - if (sitemapId === 3) + if (sitemapId === 2) return buildSlugSitemap( '/api/researchhubpost/?document_type=PREREGISTRATION&page_size=1000', '/proposal/' ); - if (sitemapId === 4) + if (sitemapId === 3) return buildSlugSitemap('/api/researchhubpost/?document_type=GRANT&page_size=100', '/grant/'); return []; diff --git a/app/topic/[slug]/latest/page.tsx b/app/topic/[slug]/latest/page.tsx deleted file mode 100644 index e513126e8..000000000 --- a/app/topic/[slug]/latest/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -'use client'; - -import { TopicFeed } from '@/components/Feed/TopicFeed'; -import { FeedTab } from '@/hooks/useFeed'; - -export default function TopicLatestPage() { - const defaultTab: FeedTab = 'latest'; - return ; -} diff --git a/app/topic/[slug]/layout.tsx b/app/topic/[slug]/layout.tsx deleted file mode 100644 index d5702edb6..000000000 --- a/app/topic/[slug]/layout.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Metadata } from 'next'; -import { HubService } from '@/services/hub.service'; -import { buildOpenGraphMetadata } from '@/lib/metadata'; - -interface GenerateMetadataProps { - params: Promise<{ slug: string }>; -} - -export async function generateMetadata({ params }: GenerateMetadataProps): Promise { - const { slug } = await params; - try { - const topic = await HubService.getHubBySlug(slug); - return buildOpenGraphMetadata({ - title: topic.name, - description: - topic.description || - `Explore ${topic.name} research papers and discussions on ResearchHub.`, - url: `/topic/${slug}`, - image: topic.imageUrl, - }); - } catch { - return {}; - } -} - -export default function TopicLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return children; -} diff --git a/app/topic/[slug]/page.tsx b/app/topic/[slug]/page.tsx deleted file mode 100644 index 77e763503..000000000 --- a/app/topic/[slug]/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { TopicFeed } from '@/components/Feed/TopicFeed'; -import { FeedTab } from '@/hooks/useFeed'; - -export default function TopicFeedPage() { - const defaultTab: FeedTab = 'popular'; - return ; -} diff --git a/app/topic/[slug]/popular/page.tsx b/app/topic/[slug]/popular/page.tsx deleted file mode 100644 index 968ae0cbb..000000000 --- a/app/topic/[slug]/popular/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -'use client'; - -import { TopicFeed } from '@/components/Feed/TopicFeed'; -import { FeedTab } from '@/hooks/useFeed'; - -export default function TopicPopularPage() { - const defaultTab: FeedTab = 'popular'; - return ; -} diff --git a/components/Feed/DocumentFeedSortAndFilters.tsx b/components/Feed/DocumentFeedSortAndFilters.tsx deleted file mode 100644 index d0542cd26..000000000 --- a/components/Feed/DocumentFeedSortAndFilters.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import { FC } from 'react'; -import { cn } from '@/utils/styles'; - -interface HomeFeedVariant { - variant: 'home'; -} - -interface TopicFeedVariant { - variant: 'topic'; - topicName: string; -} - -type DocumentFeedSortAndFiltersProps = { - className?: string; -} & (HomeFeedVariant | TopicFeedVariant); - -export const DocumentFeedSortAndFilters: FC = (props) => { - const { className } = props; - - return ( -
- {props.variant === 'home' ? ( - Your personalized research briefing. - ) : ( - Your {props.topicName} Research Briefing. - )} -
- ); -}; diff --git a/components/Feed/FeedItemJournalBadge.tsx b/components/Feed/FeedItemJournalBadge.tsx new file mode 100644 index 000000000..3df98688e --- /dev/null +++ b/components/Feed/FeedItemJournalBadge.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FC } from 'react'; +import { HashtagBadge } from '@/components/ui/badges/HashtagBadge'; +import { Journal } from '@/types/journal'; +import { getSourceLogo } from '@/utils/preprintUtil'; + +interface FeedItemJournalBadgeProps { + journal?: Journal; +} + +/** + * The hashtag-style journal badge (e.g. #biorxiv) on a paper feed item. Only + * the ResearchHub Journal has a page to link to. + */ +export const FeedItemJournalBadge: FC = ({ journal }) => { + if (!journal?.name || !journal.slug) return null; + const logo = getSourceLogo(journal.slug); + if (!logo) return null; + return ( + + ); +}; diff --git a/components/Feed/FeedItemTopicBadges.tsx b/components/Feed/FeedItemTopicBadges.tsx deleted file mode 100644 index 4b98e605c..000000000 --- a/components/Feed/FeedItemTopicBadges.tsx +++ /dev/null @@ -1,112 +0,0 @@ -'use client'; - -import { FC, useRef, useState, useEffect, useCallback } from 'react'; -import { HashtagBadge } from '@/components/ui/badges/HashtagBadge'; -import { Topic } from '@/types/topic'; -import { Journal } from '@/types/journal'; -import { EXCLUDED_TOPIC_SLUGS } from '@/constants/topics'; -import { getSourceLogo } from '@/utils/preprintUtil'; - -interface FeedItemTopicBadgesProps { - journal?: Journal; - category?: Topic; - subcategory?: Topic; - topics?: Topic[]; -} - -/** - * Renders topic and journal hashtag-style badges (e.g. #biorxiv, #chemistry) - * for paper, post, and grant feed items. - */ -export const FeedItemTopicBadges: FC = ({ - journal, - category, - subcategory, - topics = [], -}) => { - const scrollRef = useRef(null); - const [showRightBlur, setShowRightBlur] = useState(false); - - const updateBlurState = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - - const hasOverflow = el.scrollWidth > el.clientWidth; - const isAtEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2; - - setShowRightBlur(hasOverflow && !isAtEnd); - }, []); - - useEffect(() => { - updateBlurState(); - - const el = scrollRef.current; - if (!el) return; - - el.addEventListener('scroll', updateBlurState, { passive: true }); - - const resizeObserver = new ResizeObserver(updateBlurState); - resizeObserver.observe(el); - - return () => { - el.removeEventListener('scroll', updateBlurState); - resizeObserver.disconnect(); - }; - }, [updateBlurState]); - - const filteredTopics = topics.filter((topic) => !EXCLUDED_TOPIC_SLUGS.includes(topic.slug)); - - const journalLogo = journal?.name ? getSourceLogo(journal.slug) : null; - const isRHJournal = journalLogo === 'rhJournal2'; - - const renderBadges = () => { - if (category?.slug || subcategory?.slug) { - return ( - <> - {category?.slug && ( - - )} - {subcategory?.slug && ( - - )} - - ); - } - - if (filteredTopics.length > 0) { - return ( - <> - {filteredTopics.map((topic) => ( - - ))} - - ); - } - - return null; - }; - - return ( -
-
- {journalLogo && journal?.slug && journal?.name && ( - - )} - {renderBadges()} -
- -
-
- ); -}; diff --git a/components/Feed/TopicFeed.tsx b/components/Feed/TopicFeed.tsx deleted file mode 100644 index a86deadf4..000000000 --- a/components/Feed/TopicFeed.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import { FC, useState, useEffect } from 'react'; -import { useParams, notFound } from 'next/navigation'; -import { PageLayout } from '@/app/layouts/PageLayout'; -import { useFeed, FeedTab } from '@/hooks/useFeed'; -import { useHub } from '@/hooks/useHub'; -import { FeedContent } from '@/components/Feed/FeedContent'; -import { DocumentFeedSortAndFilters } from '@/components/Feed/DocumentFeedSortAndFilters'; - -interface TopicFeedProps { - defaultTab: FeedTab; -} - -const getTopicOrdering = (tab: FeedTab): string | undefined => { - if (tab === 'popular') return 'hot_score_v2'; - if (tab === 'latest') return 'latest'; - return undefined; -}; - -export const TopicFeed: FC = ({ defaultTab }) => { - const params = useParams(); - const slug = params?.slug; - const decodedSlug = typeof slug === 'string' ? decodeURIComponent(slug) : null; - const [isNavigating, setIsNavigating] = useState(false); - - const { hub, isLoading: isHubLoading, error: hubError } = useHub(decodedSlug); - - const { - entries, - isLoading: isFeedLoading, - hasMore, - loadMore, - restoredScrollPosition, - page, - lastClickedEntryId, - } = useFeed(defaultTab, { - hubSlug: decodedSlug || '', - ordering: getTopicOrdering(defaultTab), - }); - - useEffect(() => { - setIsNavigating(false); - }, [defaultTab]); - - if (!isHubLoading && (hubError || !hub)) { - notFound(); - } - - const isLoading = isHubLoading || isFeedLoading || isNavigating; - - return ( - - {hub &&

{hub.name}

} - : undefined - } - ordering={getTopicOrdering(defaultTab)} - restoredScrollPosition={restoredScrollPosition} - page={page} - lastClickedEntryId={lastClickedEntryId ?? undefined} - /> -
- ); -}; diff --git a/components/Feed/items/DeprecatedFeedItemBounty.tsx b/components/Feed/items/DeprecatedFeedItemBounty.tsx index 66dcc2d2f..84b1b026f 100644 --- a/components/Feed/items/DeprecatedFeedItemBounty.tsx +++ b/components/Feed/items/DeprecatedFeedItemBounty.tsx @@ -8,7 +8,6 @@ import { MetadataSection, PrimaryActionSection, } from '@/components/Feed/BaseFeedItem'; -import { FeedItemTopicBadges } from '@/components/Feed/FeedItemTopicBadges'; import { AuthorList } from '@/components/ui/AuthorList'; import { Avatar } from '@/components/ui/Avatar'; import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; @@ -39,7 +38,7 @@ interface DeprecatedFeedItemBountyProps { } /** - * Extracts the bounty, title, authors, topics, and other data from the entry + * Extracts the bounty, title, authors, and other data from the entry * regardless of whether it's a true BOUNTY contentType or a PAPER/POST entry * with bounties attached (as created by BountyService). */ @@ -60,9 +59,6 @@ function extractBountyData(entry: FeedEntry) { verified: a.authorProfile?.user?.isVerified, authorUrl: a.authorProfile?.profileUrl, })) ?? [], - topics: relatedWork?.topics ?? [], - category: relatedWork?.category, - subcategory: relatedWork?.subcategory, postType: relatedWork?.postType, commentContent: bountyContent.comment?.content, commentFormat: bountyContent.comment?.contentFormat, @@ -99,9 +95,6 @@ function extractBountyData(entry: FeedEntry) { }, ] : [], - topics: 'topics' in content ? (content.topics ?? []) : [], - category: 'category' in content ? content.category : undefined, - subcategory: 'subcategory' in content ? content.subcategory : undefined, postType: 'postType' in content ? content.postType : undefined, commentContent: bounty?.comment?.content, commentFormat: bounty?.comment?.contentFormat as ContentFormat | undefined, @@ -132,9 +125,6 @@ export const DeprecatedFeedItemBounty: FC = ({ bounty, title, authors, - topics, - category, - subcategory, postType, commentContent, commentFormat, @@ -265,9 +255,6 @@ export const DeprecatedFeedItemBounty: FC = ({ showBountyInfo={false} onFeedItemClick={onFeedItemClick} hideReportButton={false} - badges={ - - } > void; showSupportAndCTAButtons?: boolean; // Show container for Support and CTA buttons showDeadline?: boolean; // Show deadline in metadata line maxLength?: number; @@ -122,7 +120,6 @@ export const FeedItemBountyComment: FC = ({ actionLabels, showFooter = true, // Default to showing the footer hideActions = false, // Prop to hide actions, similar to FeedItemComment - onTopicClick, showSupportAndCTAButtons = true, // Show container for Support and CTA buttons showDeadline = true, // Show deadline in metadata line maxLength, @@ -334,12 +331,7 @@ export const FeedItemBountyComment: FC = ({ onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > - +
)} diff --git a/components/Feed/items/FeedItemPaper.tsx b/components/Feed/items/FeedItemPaper.tsx index 90c1817f1..87ff0f133 100644 --- a/components/Feed/items/FeedItemPaper.tsx +++ b/components/Feed/items/FeedItemPaper.tsx @@ -12,7 +12,7 @@ import { FeedItemTopSection, } from '@/components/Feed/BaseFeedItem'; import { FeedItemAbstractSection } from '@/components/Feed/FeedItemAbstractSection'; -import { FeedItemTopicBadges } from '@/components/Feed/FeedItemTopicBadges'; +import { FeedItemJournalBadge } from '@/components/Feed/FeedItemJournalBadge'; import { AuthorList } from '@/components/ui/AuthorList'; import { Tooltip } from '@/components/ui/Tooltip'; import { PopularityScoreTooltip } from '@/components/tooltips/HotScoreTooltip'; @@ -101,14 +101,7 @@ export const FeedItemPaper: FC = ({ /> ) : undefined } - badges={ - - } + badges={} > {/* Top section with mobile image */} = ({ leftContent={null} /> -
- -
- {/* Main content layout with desktop image */} ( @@ -36,8 +35,6 @@ export const BountySkeleton: FC = () => (
- - diff --git a/components/Feed/skeletons/PaperPostSkeleton.tsx b/components/Feed/skeletons/PaperPostSkeleton.tsx index b5d7b2f4d..ca6b837e2 100644 --- a/components/Feed/skeletons/PaperPostSkeleton.tsx +++ b/components/Feed/skeletons/PaperPostSkeleton.tsx @@ -5,7 +5,6 @@ import { SkeletonDescriptionLines, SkeletonFeedItemActionsFooter, SkeletonFeedItemHeader, - SkeletonTopicBadges, } from './shared'; interface PaperPostSkeletonProps { @@ -31,8 +30,6 @@ export const PaperPostSkeleton: FC = ({
- -
{showImage && } diff --git a/components/Feed/skeletons/shared.tsx b/components/Feed/skeletons/shared.tsx index 786e97589..667738801 100644 --- a/components/Feed/skeletons/shared.tsx +++ b/components/Feed/skeletons/shared.tsx @@ -30,15 +30,6 @@ export const SkeletonFeedItemHeader: FC<{ actionWidth?: string; className?: stri ); -/** Hashtag-style topic / hub badges โ€” matches `FeedItemTopicBadges`. */ -export const SkeletonTopicBadges: FC = () => ( -
-
-
-
-
-); - /** Desktop preview image on the right โ€” matches paper/post `cardImage` column. */ export const SkeletonCardImageRight: FC = () => (
diff --git a/components/Paper/RelatedWorkCard.tsx b/components/Paper/RelatedWorkCard.tsx index 3c162db16..4f4fd9e65 100644 --- a/components/Paper/RelatedWorkCard.tsx +++ b/components/Paper/RelatedWorkCard.tsx @@ -2,9 +2,6 @@ import { Work } from '@/types/work'; import { AuthorList } from '@/components/ui/AuthorList'; import { ContentTypeBadge } from '@/components/ui/ContentTypeBadge'; import { truncateText } from '@/utils/stringUtils'; -import { HashtagBadge } from '@/components/ui/badges/HashtagBadge'; -import { Topic } from '@/types/topic'; -import { EXCLUDED_TOPIC_SLUGS } from '@/constants/topics'; import { Fundraise } from '@/types/feed'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { formatRSC } from '@/utils/number'; @@ -13,7 +10,6 @@ import { buildWorkUrl } from '@/utils/url'; interface RelatedWorkCardProps { work: Work; onClick?: () => void; - onTopicClick?: (topic: Topic) => void; size?: 'default' | 'sm' | 'lg' | 'xs'; fundraiseData?: Fundraise; onFeedItemClick?: () => void; @@ -22,7 +18,6 @@ interface RelatedWorkCardProps { export const RelatedWorkCard = ({ work, onClick, - onTopicClick, size = 'default', fundraiseData, onFeedItemClick, @@ -173,29 +168,6 @@ export const RelatedWorkCard = ({
{displayAbstract}
)} - {/* Topics */} -
- {(work.topics || []) - .filter((topic) => !EXCLUDED_TOPIC_SLUGS.includes(topic.slug)) - .slice(0, 2) - .map((topic) => { - return ( -
{ - e.stopPropagation(); - if (onTopicClick) { - onTopicClick(topic); - } - // Otherwise, let the HashtagBadge handle navigation - }} - > - -
- ); - })} -
- {/* Funding Goal */} {fundraiseData?.goalAmount?.usd && (
diff --git a/components/Search/Search.tsx b/components/Search/Search.tsx index 4c547ad51..51f7e4c39 100644 --- a/components/Search/Search.tsx +++ b/components/Search/Search.tsx @@ -81,13 +81,6 @@ export function Search({ } } else if (suggestion.entityType === 'user' || suggestion.entityType === 'author') { navigateToAuthorProfile(suggestion.authorProfile.id); - } else if (suggestion.entityType === 'hub') { - // Handle topic navigation - if ('slug' in suggestion && suggestion.slug) { - router.push(`/topic/${suggestion.slug}`); - } else { - router.push(`/topic/${suggestion.id}`); - } } else if (suggestion.entityType === 'post') { router.push(`/post/${suggestion.id}`); } else { diff --git a/components/Search/SearchModal.tsx b/components/Search/SearchModal.tsx index 29d24473e..be8b1fa3e 100644 --- a/components/Search/SearchModal.tsx +++ b/components/Search/SearchModal.tsx @@ -61,12 +61,6 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { } } else if (suggestion.entityType === 'user' || suggestion.entityType === 'author') { navigateToAuthorProfile(suggestion.authorProfile.id); - } else if (suggestion.entityType === 'hub') { - if ('slug' in suggestion && suggestion.slug) { - router.push(`/topic/${suggestion.slug}`); - } else { - router.push(`/topic/${suggestion.id}`); - } } else if (suggestion.entityType === 'post') { router.push(`/post/${suggestion.id}`); } diff --git a/components/Search/SearchSuggestions.tsx b/components/Search/SearchSuggestions.tsx index ad1eda2d5..2b5ab27dc 100644 --- a/components/Search/SearchSuggestions.tsx +++ b/components/Search/SearchSuggestions.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; -import { FileText, History, Search, X, ArrowRight, User, Hash, HelpCircle } from 'lucide-react'; +import { FileText, History, Search, X, ArrowRight, User, HelpCircle } from 'lucide-react'; import Icon from '@/components/ui/icons/Icon'; import { cn } from '@/utils/styles'; import { SearchSuggestion } from '@/types/search'; -import { FollowTopicButton } from '@/components/ui/FollowTopicButton'; import { Avatar } from '@/components/ui/Avatar'; import { VerifiedBadge } from '@/components/ui/VerifiedBadge'; @@ -54,13 +53,7 @@ export function SearchSuggestions({ return null; } - const handleSelect = (suggestion: SearchSuggestion, e: React.MouseEvent) => { - // Don't select if we clicked on a FollowTopicButton - if ((e.target as HTMLElement).closest('.follow-topic-btn')) { - return; - } - - // Call the onSelect handler + const handleSelect = (suggestion: SearchSuggestion) => { if (onSelect) { onSelect(suggestion); } @@ -86,7 +79,6 @@ export function SearchSuggestions({ const isPaperSuggestion = suggestion.entityType === 'paper'; const isUserSuggestion = suggestion.entityType === 'user' || suggestion.entityType === 'author'; - const isTopicSuggestion = suggestion.entityType === 'hub'; const isPostSuggestion = suggestion.entityType === 'post'; const isGrantPost = isPostSuggestion && suggestion?.documentType === 'GRANT'; const isProposalRequest = isPostSuggestion && suggestion?.documentType === 'PREREGISTRATION'; @@ -131,13 +123,6 @@ export function SearchSuggestions({ return false; }; - const safeGetPaperCount = () => { - if (isTopicSuggestion && 'paperCount' in suggestion) { - return suggestion.paperCount ? `${suggestion.paperCount} papers` : 'Research Topic'; - } - return 'Research Topic'; - }; - // Get the smaller icon variant for dropdown mode const getSmallIcon = () => { if (suggestion.isRecent) @@ -158,8 +143,6 @@ export function SearchSuggestions({ ); } - if (isTopicSuggestion) - return ; if (isGrantPost || isProposalRequest) return (
@@ -176,7 +159,7 @@ export function SearchSuggestions({
  • handleSelect(suggestion, e)} + onClick={() => handleSelect(suggestion)} >
    {getSmallIcon()}
    @@ -195,19 +178,7 @@ export function SearchSuggestions({ {isUserSuggestion && (

    {safeGetHeadline()}

    )} - {isTopicSuggestion && ( -

    - Topic โ€ข {safeGetPaperCount()} -

    - )}
    - {isTopicSuggestion && suggestion.id && ( - - )}
  • ); @@ -218,7 +189,7 @@ export function SearchSuggestions({
  • handleSelect(suggestion, e)} + onClick={() => handleSelect(suggestion)} >
    @@ -239,18 +210,8 @@ export function SearchSuggestions({ {isUserSuggestion && (
    {safeGetHeadline()}
    )} - {isTopicSuggestion && ( -
    Topic โ€ข {safeGetPaperCount()}
    - )}
    - {isTopicSuggestion && suggestion.id && ( - - )}
  • ); diff --git a/components/Search/TopicSearch.tsx b/components/Search/TopicSearch.tsx deleted file mode 100644 index d2fdb766a..000000000 --- a/components/Search/TopicSearch.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { Search as SearchIcon, X } from 'lucide-react'; -import { cn } from '@/utils/styles'; -import { SearchService } from '@/services/search.service'; -import { SearchSuggestion } from '@/types/search'; - -interface TopicSearchProps { - onSearch?: (query: string) => void; - onSearchResults?: (results: SearchSuggestion[]) => void; - onSearching?: (isSearching: boolean) => void; - className?: string; - placeholder?: string; -} - -export function TopicSearch({ - onSearch, - onSearchResults, - onSearching, - className, - placeholder = 'Search topics...', -}: TopicSearchProps) { - const [query, setQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [isFocused, setIsFocused] = useState(false); - const containerRef = useRef(null); - const searchTimeoutRef = useRef(undefined); - - // Handle search with debouncing - useEffect(() => { - const performSearch = async () => { - if (query.trim()) { - try { - setIsSearching(true); - onSearching?.(true); - // Search only in the 'hub' index for topics - const results = await SearchService.getSuggestions(query, 'hub'); - setSearchResults(results); - onSearchResults?.(results); - } catch (error) { - console.error('Error searching topics:', error); - setSearchResults([]); - onSearchResults?.([]); - } finally { - setIsSearching(false); - onSearching?.(false); - } - } else { - setSearchResults([]); - onSearchResults?.([]); - } - }; - - // Clear previous timeout - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - - // Debounce search - searchTimeoutRef.current = setTimeout(performSearch, 300); - - // Notify parent of query change - onSearch?.(query); - - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, [query]); // Only re-run when query changes - - // Handle clicks outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsFocused(false); - } - } - - if (isFocused) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isFocused]); - - const handleClearSearch = () => { - setQuery(''); - setSearchResults([]); - onSearchResults?.([]); - }; - - // Results are now handled by parent component - - return ( -
    -
    - - setQuery(e.target.value)} - onFocus={() => setIsFocused(true)} - /> - {query && ( - - )} -
    - - {/* Search results are handled by parent component */} -
    - ); -} diff --git a/components/Topic/ManageTopics.tsx b/components/Topic/ManageTopics.tsx deleted file mode 100644 index 3d40ddb73..000000000 --- a/components/Topic/ManageTopics.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client'; - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import { Topic } from '@/types/topic'; -import { BrowsePageHeader } from '@/app/browse/components/BrowsePageHeader'; -import { useFollowContext } from '@/contexts/FollowContext'; -import { SearchSuggestion } from '@/types/search'; -import { TopicList } from './TopicList'; -import { TopicListSkeleton } from '@/components/skeletons/TopicListSkeleton'; -import { TopicSearch } from '@/components/Search/TopicSearch'; - -interface ManageTopicsProps { - initialTopics?: Topic[]; - showFollowingTab?: boolean; - defaultTab?: 'all' | 'following'; - className?: string; - topicListVariant?: 'default' | 'compact'; - showTitle?: boolean; -} - -export function ManageTopics({ - initialTopics = [], - showFollowingTab = true, - defaultTab = 'all', - className = '', - topicListVariant = 'default', - showTitle = true, -}: ManageTopicsProps) { - const [activeTab, setActiveTab] = useState<'all' | 'following'>(defaultTab); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [hasSearched, setHasSearched] = useState(false); - const { followedTopics, followedTopicObjects, getFollowedTopicObjects } = useFollowContext(); - - useEffect(() => { - const shouldFetchFollowedTopics = activeTab === 'following' && showFollowingTab; - if (shouldFetchFollowedTopics) { - getFollowedTopicObjects(); - } - }, [activeTab, getFollowedTopicObjects, showFollowingTab]); - - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - if (!query) { - setSearchResults([]); - setIsSearching(false); - setHasSearched(false); - } else { - // Reset hasSearched when starting a new search - setHasSearched(false); - } - }, []); - - const handleSearchResults = useCallback( - (results: SearchSuggestion[]) => { - const isAllTab = activeTab === 'all'; - if (isAllTab) { - setSearchResults(results); - } - }, - [activeTab] - ); - - const handleSearching = useCallback( - (searching: boolean) => { - const isAllTab = activeTab === 'all'; - if (isAllTab) { - setIsSearching(searching); - if (!searching) { - // Mark that we've completed a search - setHasSearched(true); - } - } - }, - [activeTab] - ); - - const handleTabChange = (tab: 'all' | 'following') => { - setActiveTab(tab); - setSearchQuery(''); - setSearchResults([]); - setIsSearching(false); - setHasSearched(false); - }; - - // Sort followed topics by createdDate (most recent first) - const sortedFollowedTopics = useMemo(() => { - return [...followedTopicObjects] - .sort((a, b) => { - // Sort by createdDate in descending order (most recent first) - return new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime(); - }) - .map((obj) => obj.data as Topic); - }, [followedTopicObjects]); - - const getDisplayTopics = () => { - const isFollowingTab = activeTab === 'following'; - const hasSearchQuery = Boolean(searchQuery); - - if (isFollowingTab) { - if (hasSearchQuery) { - const query = searchQuery.toLowerCase(); - return sortedFollowedTopics.filter( - (topic) => - topic.name.toLowerCase().includes(query) || - (topic.description && topic.description.toLowerCase().includes(query)) - ); - } - return sortedFollowedTopics; - } - - return null; - }; - - const displayTopics = getDisplayTopics(); - - // View state booleans - const isAllTab = activeTab === 'all'; - const hasSearchQuery = Boolean(searchQuery); - const isSearchingTopics = isSearching || (hasSearchQuery && !hasSearched); - const hasNoSearchResults = hasSearched && searchResults.length === 0 && hasSearchQuery; - const hasSearchResults = searchResults.length > 0 && hasSearchQuery; - const showAllTopics = !hasSearchQuery && isAllTab; - const hasFollowedTopics = displayTopics && displayTopics.length > 0; - const showFollowedSearchCount = hasSearchQuery && hasFollowedTopics && !isSearching; - const noFollowedTopicsMessage = hasSearchQuery - ? `No followed topics found matching "${searchQuery}"` - : 'You are not following any topics yet'; - - return ( -
    - {showFollowingTab ? ( - - ) : ( -
    - -
    - )} - -
    - {isAllTab ? ( - <> - {hasSearchQuery && ( - <> - {isSearchingTopics && } - - {hasNoSearchResults && ( -
    - No topics found matching "{searchQuery}" -
    - )} - - {hasSearchResults && ( - <> -
    -

    - {searchResults.length} topic{searchResults.length !== 1 ? 's' : ''} found -

    -
    - - - ({ - id: result.id || 0, - name: result.displayName || '', - slug: 'slug' in result && result.slug ? result.slug : `${result.id}`, - description: 'description' in result ? result.description : undefined, - }) as Topic - )} - variant={topicListVariant} - /> - - )} - - )} - - {showAllTopics && } - - ) : ( - <> - {hasFollowedTopics ? ( - <> - {showFollowedSearchCount && ( -
    -

    - {displayTopics.length} topic{displayTopics.length !== 1 ? 's' : ''} found -

    -
    - )} - - - ) : ( -
    {noFollowedTopicsMessage}
    - )} - - )} -
    -
    - ); -} diff --git a/components/Topic/TopicEmojis.ts b/components/Topic/TopicEmojis.ts deleted file mode 100644 index 9c0ccb616..000000000 --- a/components/Topic/TopicEmojis.ts +++ /dev/null @@ -1,277 +0,0 @@ -// Topic emoji mapping for fallbacks when API doesn't provide icons -export const TOPIC_EMOJIS: Record = { - // Primary hubs (from categories) - biology: '๐Ÿงฌ', - chemistry: '๐Ÿงช', - 'computer-science': '๐Ÿ’ป', - physics: 'โš›๏ธ', - economics: '๐Ÿ“ˆ', - medicine: '๐Ÿฅ', - mathematics: '๐Ÿ”ข', - engineering: 'โš™๏ธ', - astrophysics: '๐ŸŒŒ', - 'social-sciences': '๐Ÿ‘ฅ', - 'nonlinear-sciences': 'โˆž', - biochemistry: '๐Ÿงซ', - statistics: '๐Ÿ“Š', - bioengineering: '๐Ÿ”ฌ', - electrochemistry: '๐Ÿ”‹', - // Biology hubs - paleontology: '๐Ÿฆ–', - pathology: '๐Ÿ”ฌ', - 'evolutionary-biology': '๐Ÿงฌ', - neuroscience: '๐Ÿง ', - bioinformatics: '๐Ÿ’ป', - 'systems-biology': '๐Ÿ”—', - genomics: '๐Ÿงฌ', - zoology: '๐Ÿฆ', - 'synthetic-biology': '๐Ÿงช', - 'developmental-biology': '๐ŸŒฑ', - ecology: '๐ŸŒฟ', - pharmacology: '๐Ÿ’Š', - 'cell-biology': '๐Ÿฆ ', - biophysics: 'โš›๏ธ', - 'molecular-biology': '๐Ÿงฌ', - physiology: 'โค๏ธ', - microbiology: '๐Ÿฆ ', - 'cancer-biology': '๐ŸŽ—๏ธ', - 'plant-biology': '๐ŸŒฑ', - 'genetics-and-genomics': '๐Ÿงฌ', - 'biological-physics': '๐Ÿ”ฌ', - 'quantitative-biology': '๐Ÿ“Š', - biomolecules: '๐Ÿงช', - 'molecular-networks': '๐Ÿ”—', - 'other-quantitative-biology': '๐Ÿ“ˆ', - 'populations-and-evolution': '๐ŸŒ', - 'quantitative-methods': '๐Ÿ“Š', - 'subcellular-processes': '๐Ÿ”ฌ', - 'tissues-and-organs': '๐Ÿซ€', - immunology: '๐Ÿ›ก๏ธ', - // Chemistry hubs - 'organic-chemistry': '๐Ÿงช', - 'computational-chemistry': '๐Ÿ’ป', - 'physical-chemistry': 'โš›๏ธ', - 'materials-science': '๐Ÿ”ง', - 'chemical-physics': 'โš›๏ธ', - 'environmental-chemistry': '๐ŸŒฟ', - 'chemical-engineering': 'โš™๏ธ', - 'nuclear-chemistry': 'โ˜ข๏ธ', - 'chemical-biology': '๐Ÿงฌ', - 'supramolecular-chemistry': '๐Ÿ”—', - catalysis: 'โšก', - 'inorganic-chemistry': '๐Ÿงช', - 'materials-chemistry': '๐Ÿงฑ', - astrochemistry: '๐ŸŒŒ', - 'chemical-education': '๐Ÿ“š', - 'food-chemistry': '๐Ÿฝ๏ธ', - geochemistry: '๐ŸŒ', - 'medicinal-chemistry': '๐Ÿ’Š', - nanochemistry: '๐Ÿ”ฌ', - 'organometallic-chemistry': 'โš›๏ธ', - 'surface-chemistry': '๐Ÿงฒ', - 'analytical-chemistry': '๐Ÿ”', - 'analytical-biochemistry': '๐Ÿ”ฌ', - // Computer Science hubs - 'machine-learning': '๐Ÿค–', - 'software-engineering': '๐Ÿ’ป', - 'computer-vision': '๐Ÿ‘๏ธ', - graphics: '๐ŸŽจ', - 'information-retrieval': '๐Ÿ”', - 'human-computer-interaction': '๐Ÿ‘ค', - 'distributed-computing': '๐ŸŒ', - 'natural-language-processing': '๐Ÿ—ฃ๏ธ', - 'game-theory': '๐ŸŽฎ', - 'discrete-mathematics': '๐Ÿ”ข', - multimedia: '๐Ÿ“น', - 'artificial-intelligence': '๐Ÿค–', - 'computational-complexity': '๐Ÿงฉ', - 'computational-geometry': '๐Ÿ“', - 'cryptography-and-security': '๐Ÿ”', - 'data-structures-and-algorithms': '๐Ÿ”ค', - databases: '๐Ÿ—„๏ธ', - 'formal-languages': '๐Ÿ“', - 'hardware-architecture': '๐Ÿ–ฅ๏ธ', - 'neural-computing': '๐Ÿง ', - networking: '๐ŸŒ', - 'operating-systems': 'โš™๏ธ', - 'programming-languages': '๐Ÿ’ป', - sound: '๐ŸŽต', - 'quantum-computing': 'โš›๏ธ', - 'computational-engineering': 'โš™๏ธ', - 'computers-and-society': '๐Ÿ‘ฅ', - 'digital-libraries': '๐Ÿ“š', - 'emerging-technologies': '๐Ÿš€', - 'general-literature': '๐Ÿ“–', - 'multiagent-systems': '๐Ÿค', - 'mathematical-software': '๐Ÿ”ข', - other: '๐Ÿ“ฆ', - performance: 'โšก', - 'symbolic-computation': '๐Ÿ”ฃ', - 'social-networks': '๐ŸŒ', - 'numerical-analysis': '๐Ÿ”ข', - logic: '๐Ÿงฉ', - robotics: '๐Ÿค–', - 'information-theory': '๐Ÿ“ก', - // Physics hubs - 'atomic-physics': 'โš›๏ธ', - optics: '๐Ÿ”ฆ', - 'computational-physics': '๐Ÿ’ป', - 'statistical-mechanics': '๐Ÿ“Š', - 'fluid-dynamics': '๐Ÿ’ง', - superconductivity: '๐Ÿงฒ', - 'soft-matter': '๐Ÿซง', - 'general-relativity': '๐ŸŒŒ', - geophysics: '๐ŸŒ', - 'mathematical-physics': '๐Ÿ“', - instrumentation: '๐Ÿ”ง', - cosmology: '๐ŸŒŒ', - 'condensed-matter': 'โ„๏ธ', - 'high-energy-experiment': 'โšก', - 'high-energy-phenomenology': '๐ŸŒŸ', - 'high-energy-theory': '๐Ÿ’ซ', - 'lattice-physics': '๐Ÿ”ท', - 'nuclear-experiment': 'โ˜ข๏ธ', - 'nuclear-theory': 'โš›๏ธ', - 'plasma-physics': '๐Ÿ”ฅ', - 'quantum-physics': 'โš›๏ธ', - 'disordered-systems': '๐ŸŒ€', - 'mesoscale-physics': '๐Ÿ”ฌ', - 'other-condensed-matter': 'โ„๏ธ', - 'quantum-gases': '๐Ÿ’จ', - 'strongly-correlated': '๐Ÿ”—', - 'general-physics': '๐Ÿ”ญ', - 'accelerator-physics': '๐Ÿš€', - 'atmospheric-physics': '๐ŸŒค๏ธ', - 'applied-physics': 'โš™๏ธ', - 'atomic-clusters': 'โš›๏ธ', - 'classical-physics': '๐ŸŽฑ', - 'physics-education': '๐Ÿ“š', - 'history-of-physics': '๐Ÿ“œ', - 'popular-physics': '๐ŸŒŸ', - 'space-physics': '๐Ÿš€', - 'data-analysis': '๐Ÿ“Š', - // Economics hubs - econometrics: '๐Ÿ“Š', - 'financial-economics': '๐Ÿ’ฐ', - 'computational-finance': '๐Ÿ’ป', - 'economic-theory': '๐Ÿ“ˆ', - 'general-economics': '๐Ÿ’ผ', - 'general-finance': '๐Ÿ’ต', - 'mathematical-finance': '๐Ÿ“', - 'portfolio-management': '๐Ÿ“Š', - 'quantitative-finance': '๐Ÿ“ˆ', - 'risk-management': 'โš ๏ธ', - 'securities-pricing': '๐Ÿ’น', - 'statistical-finance': '๐Ÿ“Š', - 'trading-and-market-microstructure': '๐Ÿ“ˆ', - // Medicine hubs - 'internal-medicine': '๐Ÿฅ', - endocrinology: '๐Ÿ’‰', - pediatrics: '๐Ÿ‘ถ', - psychiatry: '๐Ÿง ', - radiology: '๐Ÿ“ท', - 'medical-physics': 'โš›๏ธ', - 'public-health': '๐Ÿฅ', - 'physical-therapy': '๐Ÿƒ', - nursing: '๐Ÿ‘ฉโ€โš•๏ธ', - cardiology: 'โค๏ธ', - dentistry: '๐Ÿฆท', - 'medical-education': '๐Ÿ“š', - ophthalmology: '๐Ÿ‘๏ธ', - 'health-informatics': '๐Ÿ’ป', - gastroenterology: '๐Ÿ”ฌ', - dermatology: '๐Ÿงด', - urology: '๐Ÿฅ', - 'obstetrics-and-gynecology': '๐Ÿ‘ถ', - neurology: '๐Ÿง ', - nephrology: '๐Ÿฅ', - hematology: '๐Ÿฉธ', - 'pain-medicine': '๐Ÿ’Š', - oncology: '๐ŸŽ—๏ธ', - 'emergency-medicine': '๐Ÿš‘', - rheumatology: '๐Ÿฆด', - 'clinical-trials': '๐Ÿงช', - 'critical-care-medicine': '๐Ÿšจ', - 'health-economics': '๐Ÿ’ฐ', - 'health-policy': '๐Ÿ“‹', - 'medical-ethics': 'โš–๏ธ', - nutrition: '๐Ÿฅ—', - 'reproductive-health': '๐Ÿ‘ถ', - 'sports-medicine': '๐Ÿƒ', - epidemiology: '๐Ÿ“Š', - anesthesiology: '๐Ÿ’‰', - 'infectious-disease': '๐Ÿฆ ', - orthopedics: '๐Ÿฆด', - otolaryngology: '๐Ÿ‘‚', - 'palliative-medicine': '๐Ÿคฒ', - pulmonology: '๐Ÿซ', - surgery: '๐Ÿฅ', - // Mathematics hubs - combinatorics: '๐Ÿ”ข', - 'algebraic-geometry': '๐Ÿ“', - 'algebraic-topology': '๐Ÿ”ท', - 'analysis-of-pdes': 'โˆ‚', - 'category-theory': '๐Ÿ”—', - 'classical-analysis': '๐Ÿ“Š', - 'commutative-algebra': '๐Ÿ”ข', - 'complex-variables': '๐ŸŒ€', - 'differential-geometry': '๐Ÿ“', - 'dynamical-systems': '๐Ÿ”„', - 'functional-analysis': '๐Ÿ“ˆ', - 'general-mathematics': '๐Ÿ”ข', - 'general-topology': '๐Ÿ”ท', - 'geometric-topology': '๐Ÿ“', - 'group-theory': '๐Ÿ”—', - 'history-and-overview': '๐Ÿ“š', - 'k-theory': '๐ŸŽฏ', - 'metric-geometry': '๐Ÿ“', - 'number-theory': '๐Ÿ”ข', - 'operator-algebras': '๐Ÿ”ค', - 'optimization-and-control': '๐ŸŽฏ', - probability: '๐ŸŽฒ', - 'quantum-algebra': 'โš›๏ธ', - 'representation-theory': '๐Ÿ”ท', - 'rings-and-algebras': 'โญ•', - 'spectral-theory': '๐ŸŒˆ', - 'symplectic-geometry': '๐Ÿ”„', - // Engineering hubs - 'signal-processing': '๐Ÿ“ก', - 'audio-and-speech-processing': '๐ŸŽค', - 'image-and-video-processing': '๐Ÿ“ท', - 'systems-and-control': 'โš™๏ธ', - // Astrophysics hubs - 'planetary-science': '๐Ÿช', - galaxies: '๐ŸŒŒ', - 'high-energy-astrophysics': '๐Ÿ’ซ', - 'solar-and-stellar': 'โ˜€๏ธ', - // Social Sciences hubs - 'physics-and-society': '๐Ÿ‘ฅ', - 'scientific-communication-and-education': '๐Ÿ“š', - // Nonlinear Sciences hubs - 'adaptation-and-self-organization': '๐Ÿ”„', - 'chaotic-dynamics': '๐ŸŒ€', - 'cellular-automata': '๐Ÿ”ฒ', - 'pattern-formation': '๐Ÿ”ท', - 'exactly-solvable-systems': 'โœ“', - // Statistics hubs - applications: '๐Ÿ“Š', - methodology: '๐Ÿ“ˆ', - 'statistics-theory': '๐Ÿ“', - 'general-statistics': '๐Ÿ“Š', - computation: '๐Ÿ’ป', - miscellaneous: '๐Ÿ“ฆ', -}; - -/** - * Get the emoji for a topic based on its name or slug - * @param topicName - The name or slug of the topic - * @returns The emoji string or null if not found - */ -export function getTopicEmoji(topicName: string): string | null { - if (!topicName) return null; - - // Convert to lowercase and replace spaces with hyphens to match our mapping keys - const normalizedName = topicName.toLowerCase().replace(/\s+/g, '-'); - - return TOPIC_EMOJIS[normalizedName] || null; -} diff --git a/components/Topic/TopicList.tsx b/components/Topic/TopicList.tsx deleted file mode 100644 index 517a597c4..000000000 --- a/components/Topic/TopicList.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { Hash, Check, FileText } from 'lucide-react'; -import { Topic } from '@/types/topic'; -import { FollowTopicButton } from '@/components/ui/FollowTopicButton'; -import { toTitleCase } from '@/utils/stringUtils'; -import { getTopicEmoji } from './TopicEmojis'; -import { useFollowContext } from '@/contexts/FollowContext'; -import { cn } from '@/utils/styles'; - -interface TopicListProps { - topics: Topic[]; - className?: string; - variant?: 'default' | 'compact'; -} - -export function TopicList({ topics, className = '', variant = 'default' }: TopicListProps) { - const { followedTopicIds, toggleFollow } = useFollowContext(); - - const handleCompactClick = async (e: React.MouseEvent, topicId: number) => { - e.preventDefault(); - await toggleFollow(topicId); - }; - - const formatPaperCount = (count: number): string => { - const formattedCount = count.toLocaleString(); - return `${formattedCount} ${count === 1 ? 'paper' : 'papers'}`; - }; - - return ( -
    - {topics.map((topic) => { - const topicTitle = toTitleCase(topic.name); - const isFollowed = followedTopicIds.includes(topic.id); - - // Get emoji from mapping based on name or slug, fallback to Hash icon - const displayIcon = getTopicEmoji(topic.name) || getTopicEmoji(topic.slug); - - if (variant === 'compact') { - return ( - - ); - } - - return ( - -
    -
    -
    - {displayIcon ? ( - {displayIcon} - ) : ( - - )} -
    - -
    -
    - -
    -
    -
    - -
    -

    - {topicTitle} -

    - {topic.paperCount && topic.paperCount > 0 && ( -
    - - - {formatPaperCount(topic.paperCount)} - -
    - )} -
    -
    - - ); - })} -
    - ); -} diff --git a/components/providers/ClientProviders.tsx b/components/providers/ClientProviders.tsx index 540abaa21..0477d5cd1 100644 --- a/components/providers/ClientProviders.tsx +++ b/components/providers/ClientProviders.tsx @@ -12,7 +12,6 @@ import { FundingPowerProvider } from '@/contexts/FundingPowerContext'; import { UserProvider } from '@/contexts/UserContext'; import { OrganizationProvider } from '@/contexts/OrganizationContext'; import { OnchainProvider } from '@/contexts/OnchainContext'; -import { FollowProvider } from '@/contexts/FollowContext'; import { NavigationProvider } from '@/contexts/NavigationContext'; import { AuthSharingWrapper } from '@/components/AuthSharingWrapper'; import { VerificationProvider } from '@/contexts/VerificationContext'; @@ -59,7 +58,7 @@ export function ClientProviders({ children, session }: ClientProvidersProps) { - {children} + {children} diff --git a/components/skeletons/TopicListSkeleton.tsx b/components/skeletons/TopicListSkeleton.tsx deleted file mode 100644 index 627b3b046..000000000 --- a/components/skeletons/TopicListSkeleton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Skeleton } from '@/components/ui/Skeleton'; - -interface TopicListSkeletonProps { - count?: number; - variant?: 'default' | 'compact'; -} - -export function TopicListSkeleton({ count = 50, variant = 'default' }: TopicListSkeletonProps) { - if (variant === 'compact') { - return ( -
    - {Array.from({ length: count }).map((_, index) => ( -
    - {/* Icon placeholder */} - - - {/* Text placeholder */} - -
    - ))} -
    - ); - } - - return ( -
    - {Array.from({ length: count }).map((_, index) => ( -
    -
    -
    - {/* Icon placeholder */} - - - {/* Follow button placeholder */} -
    - - {/* Topic name placeholder */} - -
    -
    - ))} -
    - ); -} diff --git a/components/ui/ButtonGroup.tsx b/components/ui/ButtonGroup.tsx deleted file mode 100644 index 932a67fbb..000000000 --- a/components/ui/ButtonGroup.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; -import { cn } from '@/utils/styles'; - -interface ButtonGroupOption { - value: string; - label: ReactNode; - badge?: string | number; - /** Extra classes while this option is the active one, e.g. its own colour. */ - activeClassName?: string; -} - -interface ButtonGroupProps { - options: ButtonGroupOption[]; - value: string; - onChange: (value: string) => void; - className?: string; - size?: 'sm' | 'md' | 'lg'; - variant?: 'default' | 'outlined' | 'pill'; -} - -export function ButtonGroup({ - options, - value, - onChange, - className, - size = 'md', - variant = 'default', -}: ButtonGroupProps) { - const sizeClasses = { - sm: 'px-3 py-1.5 text-xs', - md: 'px-4 py-2 text-sm', - lg: 'px-5 py-2.5 text-base', - }; - - const containerPadding = { - sm: 'p-0.5', - md: 'p-1', - lg: 'p-1.5', - }; - - const containerClasses = () => { - switch (variant) { - case 'outlined': - return 'flex gap-0 border border-gray-300 bg-white rounded-lg'; - case 'pill': - return 'flex gap-2 p-1 border border-gray-300 bg-white rounded-lg'; - default: - return cn('flex gap-1 bg-gray-100 rounded-xl', containerPadding[size]); - } - }; - - const buttonClasses = (isActive: boolean) => { - switch (variant) { - case 'outlined': - return cn( - 'font-medium transition-all flex items-center gap-2 border-r border-gray-300 last:border-r-0 first:rounded-l-lg last:rounded-r-lg whitespace-nowrap', - sizeClasses[size], - isActive ? 'bg-gray-100 text-gray-900' : 'bg-white text-gray-600 hover:bg-gray-50' - ); - - case 'pill': - return cn( - 'font-medium transition-all flex items-center gap-2 rounded-md whitespace-nowrap', - sizeClasses[size], - isActive ? 'bg-gray-200 text-gray-900' : 'text-gray-600 hover:bg-gray-100' - ); - - default: - return cn( - 'font-medium rounded-lg transition-all flex items-center gap-2 whitespace-nowrap', - sizeClasses[size], - isActive ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900' - ); - } - }; - - const badgeClasses = (isActive: boolean) => { - switch (variant) { - case 'outlined': - return 'bg-gray-200 text-gray-600 rounded font-medium'; - case 'pill': - return 'bg-gray-300 text-gray-700 rounded font-medium'; - default: - return 'bg-gray-200 text-gray-700 rounded font-medium'; - } - }; - - return ( -
    - {options.map((option) => ( - - ))} -
    - ); -} diff --git a/components/ui/FollowTopicButton.tsx b/components/ui/FollowTopicButton.tsx deleted file mode 100644 index 361164b8d..000000000 --- a/components/ui/FollowTopicButton.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/Button'; -import { HubService } from '@/services/hub.service'; -import { useFollowContext } from '@/contexts/FollowContext'; - -interface FollowTopicButtonProps { - topicId: number; - size?: 'sm' | 'default'; - className?: string; -} - -export function FollowTopicButton({ - topicId, - size = 'sm', - className = '', -}: FollowTopicButtonProps) { - const { isFollowing, toggleFollow } = useFollowContext(); - const [isLoading, setIsLoading] = useState(false); - const [optimisticFollowing, setOptimisticFollowing] = useState(null); - const [isHovered, setIsHovered] = useState(false); - - // Check if this topic is being followed - const following = optimisticFollowing !== null ? optimisticFollowing : isFollowing(topicId); - - const handleFollowToggle = async (e: React.MouseEvent) => { - // Stop event propagation to prevent parent click handlers from firing - e.stopPropagation(); - e.preventDefault(); - - if (isLoading) return; - - // Optimistically update the UI - const currentFollowingState = isFollowing(topicId); - setOptimisticFollowing(!currentFollowingState); - - setIsLoading(true); - try { - // This will update the global context state - await toggleFollow(topicId); - // Clear optimistic state on success - setOptimisticFollowing(null); - } catch (error) { - console.error('Error toggling follow status:', error); - // Revert optimistic update on error - setOptimisticFollowing(null); - } finally { - setIsLoading(false); - } - }; - - const getButtonText = () => { - if (following && isHovered) return 'Unfollow'; - if (following) return 'Following'; - return 'Follow'; - }; - - const getButtonStyles = () => { - if (following && isHovered) { - return 'text-red-600 border-red-300 hover:border-red-400 hover:bg-red-50'; - } - if (following) { - return 'text-gray-600 border-gray-300'; - } - return 'bg-gray-100 text-gray-900 hover:bg-gray-200'; - }; - - return ( - - ); -} diff --git a/components/ui/badges/HashtagBadge.tsx b/components/ui/badges/HashtagBadge.tsx index 216a11d7b..968f87f83 100644 --- a/components/ui/badges/HashtagBadge.tsx +++ b/components/ui/badges/HashtagBadge.tsx @@ -3,17 +3,30 @@ import Link from 'next/link'; interface HashtagBadgeProps { label: string; - href: string; + /** Where the badge goes; without one it is plain text. */ + href?: string; className?: string; } -export const HashtagBadge: FC = ({ label, href, className = '' }) => ( - e.stopPropagation()} - > - #{label} - -); +const STYLE = { fontSize: '13px' }; + +export const HashtagBadge: FC = ({ label, href, className = '' }) => { + const baseClass = `flex-shrink-0 text-gray-500 ${className}`; + if (!href) { + return ( + + #{label} + + ); + } + return ( + e.stopPropagation()} + > + #{label} + + ); +}; diff --git a/components/work/WorkRightSidebar.tsx b/components/work/WorkRightSidebar.tsx index b9c1966f4..19ac4024c 100644 --- a/components/work/WorkRightSidebar.tsx +++ b/components/work/WorkRightSidebar.tsx @@ -2,7 +2,6 @@ import { Work } from '@/types/work'; import type { WorkMetadata } from '@/services/metadata.service'; -import { TopicsSection } from './components/TopicsSection'; import { DOISection } from './components/DOISection'; import { LicenseSection } from './components/LicenseSection'; import { FormatsSection } from './components/FormatsSection'; @@ -40,7 +39,6 @@ export const WorkRightSidebar = ({ work, metadata }: WorkRightSidebarProps) => { })} /> )} - {work.contentType === 'paper' && } {work.doi && } {work.postType !== 'QUESTION' && } diff --git a/components/work/components/JournalSection.tsx b/components/work/components/JournalSection.tsx index f5c48fe81..5c4a6f1b2 100644 --- a/components/work/components/JournalSection.tsx +++ b/components/work/components/JournalSection.tsx @@ -19,45 +19,44 @@ export const JournalSection = ({ journal }: JournalSectionProps) => { const isRHJournal = logo === 'rhJournal2'; const isPreprint = isPreprintServer(journal.slug); const displayName = logo ? getPreprintDisplayName(journal.slug) : journal.name; - const href = isRHJournal ? '/journal' : `/topic/${journal.slug}`; const sectionTitle = isPreprint ? 'Preprint Server' : 'Journal'; + const imageSrc = logo || journal.imageUrl; + + const badge = ( + + {isRHJournal ? ( + <> + + RH Journal + + ) : imageSrc ? ( + {journal.name} + ) : ( + {displayName} + )} + + ); return (
    - - - {isRHJournal ? ( - <> - - RH Journal - - ) : logo ? ( - {journal.name} - ) : journal.imageUrl ? ( - {journal.name} - ) : ( - {displayName} - )} - - + {isRHJournal ? ( + + {badge} + + ) : ( + {badge} + )}
    ); }; diff --git a/components/work/components/TopicsSection.tsx b/components/work/components/TopicsSection.tsx deleted file mode 100644 index 5b75866a0..000000000 --- a/components/work/components/TopicsSection.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client'; - -import { ChevronDown } from 'lucide-react'; -import { useState, useMemo } from 'react'; -import { HashtagBadge } from '@/components/ui/badges/HashtagBadge'; -import { EXCLUDED_TOPIC_SLUGS } from '@/constants/topics'; -import { SidebarHeader } from '@/components/ui/SidebarHeader'; - -interface Topic { - id: string | number; - name: string; - slug: string; - namespace?: 'journal' | 'topic' | 'category' | 'subcategory'; -} - -interface TopicsSectionProps { - topics: Topic[]; -} - -export const TopicsSection = ({ topics }: TopicsSectionProps) => { - const [showAllTopics, setShowAllTopics] = useState(false); - - const sortedTopics = useMemo(() => { - const filtered = topics.filter((topic) => !EXCLUDED_TOPIC_SLUGS.includes(topic.slug)); - - const categories = filtered.filter((t) => t.namespace === 'category'); - const subcategories = filtered.filter((t) => t.namespace === 'subcategory'); - const others = filtered.filter( - (t) => t.namespace !== 'category' && t.namespace !== 'subcategory' - ); - - categories.sort((a, b) => Number(a.id) - Number(b.id)); - subcategories.sort((a, b) => Number(a.id) - Number(b.id)); - - const result: Topic[] = []; - - if (categories.length > 0) { - result.push(categories[0]); - } - - if (subcategories.length > 0) { - result.push(subcategories[0]); - } - - result.push(...categories.slice(1)); - result.push(...subcategories.slice(1)); - result.push(...others); - - return result; - }, [topics]); - - if (sortedTopics.length === 0) return null; - - const displayedTopics = showAllTopics ? sortedTopics : sortedTopics.slice(0, 5); - const hasMoreTopics = sortedTopics.length > 5; - - return ( -
    - -
    -
    - {displayedTopics.map((topic) => ( - - ))} -
    - {hasMoreTopics && ( - - )} -
    -
    - ); -}; diff --git a/constants/topics.ts b/constants/topics.ts deleted file mode 100644 index dc97b609a..000000000 --- a/constants/topics.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Topic slugs to exclude from topic lists -export const EXCLUDED_TOPIC_SLUGS: string[] = ['biorxiv-community-reviews']; diff --git a/contexts/FollowContext.tsx b/contexts/FollowContext.tsx deleted file mode 100644 index 98ecc860b..000000000 --- a/contexts/FollowContext.tsx +++ /dev/null @@ -1,220 +0,0 @@ -'use client'; - -import { - createContext, - useState, - useContext, - useEffect, - useMemo, - useCallback, - ReactNode, -} from 'react'; -import { useSession } from 'next-auth/react'; -import { HubService } from '@/services/hub.service'; -import { FollowService } from '@/services/follow.service'; -import { Topic } from '@/types/topic'; -import { FollowedObject } from '@/types/follow'; - -interface FollowContextType { - followedTopicIds: number[]; - followedTopics: Topic[]; - followedTopicObjects: FollowedObject[]; - isFollowing: (topicId: number) => boolean; - toggleFollow: (topicId: number) => Promise; - followMultiple: (topicIds: number[]) => Promise; - refreshFollowed: () => Promise; - loading: boolean; - getFollowedTopics: () => Promise; - getFollowedTopicObjects: () => Promise; - /** Opts the caller into the lazy initial fetch. Called for you by `useFollowContext`. */ - activate: () => void; -} - -const FollowContext = createContext(undefined); - -export function FollowProvider({ children }: { children: ReactNode }) { - const { status } = useSession(); - const [followedTopicIds, setFollowedTopicIds] = useState([]); - const [followedTopics, setFollowedTopics] = useState([]); - const [followedTopicObjects, setFollowedTopicObjects] = useState([]); - const [loading, setLoading] = useState(true); - const [isActivated, setIsActivated] = useState(false); - - const activate = useCallback(() => setIsActivated(true), []); - - // Function to refresh the list of followed topics - const refreshFollowed = useCallback(async () => { - setLoading(true); - try { - // Fetch full topic data with metadata - const followedObjects = await FollowService.getFollowedTopics(); - - // Extract topics and IDs - const topics = followedObjects.map((obj) => obj.data as Topic); - const topicIds = topics.map((topic) => topic.id); - - setFollowedTopicObjects(followedObjects); - setFollowedTopics(topics); - setFollowedTopicIds(topicIds); - } catch (error) { - console.error('Error fetching followed topics:', error); - } finally { - setLoading(false); - } - }, []); - - // This provider wraps the whole app, so fetching on mount cost every page an - // `/api/hub/following/` request even when nothing on screen showed follows. - // Instead the first `useFollowContext` consumer to mount activates it, and - // signed-out visitors never follow anything so they skip the call entirely. - useEffect(() => { - if (!isActivated || status === 'loading') return; - - if (status !== 'authenticated') { - setFollowedTopicIds([]); - setFollowedTopics([]); - setFollowedTopicObjects([]); - setLoading(false); - return; - } - - refreshFollowed(); - }, [isActivated, status, refreshFollowed]); - - // Check if a topic is being followed - const isFollowing = (topicId: number): boolean => { - return followedTopicIds.includes(topicId); - }; - - // Toggle follow status for a topic - const toggleFollow = async (topicId: number) => { - const currentlyFollowing = isFollowing(topicId); - - // Optimistically update the UI - setFollowedTopicIds((prev) => - currentlyFollowing ? prev.filter((id) => id !== topicId) : [...prev, topicId] - ); - - try { - if (currentlyFollowing) { - await HubService.unfollowHub(topicId); - } else { - await HubService.followHub(topicId); - } - // Refresh the full data after successful toggle - await refreshFollowed(); - } catch (error) { - console.error('Error toggling follow status:', error); - // Revert on error - setFollowedTopicIds((prev) => - currentlyFollowing ? [...prev, topicId] : prev.filter((id) => id !== topicId) - ); - // Re-throw the error for components to handle - throw error; - } - }; - - // Follow multiple topics at once - const followMultiple = async (topicIds: number[]) => { - // Filter out already followed topics - const toFollow = topicIds.filter((id) => !followedTopicIds.includes(id)); - if (toFollow.length === 0) return; - - // Optimistically update the UI - setFollowedTopicIds((prev) => [...prev, ...toFollow]); - - try { - const response = await FollowService.followMultipleHubs(toFollow); - - // Log any issues for debugging - if (response.not_found.length > 0) { - console.warn('Topics not found:', response.not_found); - } - if (response.already_following.length > 0) { - console.info('Already following:', response.already_following); - } - - // Refresh the full data after successful follow - await refreshFollowed(); - } catch (error) { - console.error('Error following multiple topics:', error); - // Revert on error - setFollowedTopicIds((prev) => prev.filter((id) => !toFollow.includes(id))); - // Re-throw the error for components to handle - throw error; - } - }; - - // Function to get followed topics (returns cached data or fetches if needed) - const getFollowedTopics = async (): Promise => { - if (followedTopics.length === 0 && followedTopicIds.length > 0) { - // If we have IDs but no topic data, fetch the full data - try { - const followedObjects = await FollowService.getFollowedTopics(); - const topics = followedObjects.map((obj) => obj.data as Topic); - setFollowedTopicObjects(followedObjects); - setFollowedTopics(topics); - return topics; - } catch (error) { - console.error('Error fetching followed topics data:', error); - return []; - } - } - return followedTopics; - }; - - // Function to get followed topic objects with metadata - const getFollowedTopicObjects = async (): Promise => { - if (followedTopicObjects.length === 0 && followedTopicIds.length > 0) { - // If we have IDs but no topic objects, fetch the full data - try { - const followedObjects = await FollowService.getFollowedTopics(); - const topics = followedObjects.map((obj) => obj.data as Topic); - setFollowedTopicObjects(followedObjects); - setFollowedTopics(topics); - return followedObjects; - } catch (error) { - console.error('Error fetching followed topic objects:', error); - return []; - } - } - return followedTopicObjects; - }; - - // Create memoized context value - const contextValue = useMemo( - () => ({ - followedTopicIds, - followedTopics, - followedTopicObjects, - isFollowing, - toggleFollow, - followMultiple, - refreshFollowed, - loading, - getFollowedTopics, - getFollowedTopicObjects, - activate, - }), - [followedTopicIds, followedTopics, followedTopicObjects, loading, activate] - ); - - return {children}; -} - -// Hook to use the context -export function useFollowContext() { - const context = useContext(FollowContext); - const activate = context?.activate; - - // Reading the context is what makes the data worth fetching, so the first - // consumer to mount triggers the provider's one-time fetch. - useEffect(() => { - activate?.(); - }, [activate]); - - if (context === undefined) { - throw new Error('useFollowContext must be used within a FollowProvider'); - } - return context; -} diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 271a4b980..8e879244e 100644 --- a/hooks/useFeed.ts +++ b/hooks/useFeed.ts @@ -4,7 +4,7 @@ import { FeedService } from '@/services/feed.service'; import { useSession } from 'next-auth/react'; import { useFeedStateRestoration } from './useFeedStateRestoration'; -export type FeedTab = 'following' | 'latest' | 'popular' | 'for-you' | 'journal'; +type FeedTab = 'following' | 'latest' | 'popular' | 'for-you' | 'journal'; export type FundingTab = 'all' | 'open' | 'closed'; export type FeedSource = 'all' | 'researchhub'; diff --git a/hooks/useFeedSource.ts b/hooks/useFeedSource.ts index 1ba81a098..e6d3c49c5 100644 --- a/hooks/useFeedSource.ts +++ b/hooks/useFeedSource.ts @@ -29,8 +29,6 @@ import { FeedSource } from '@/types/analytics'; * - /peer-review โ†’ source: 'peer-review', tab: 'unknown' * - /fund โ†’ source: 'fund', tab: 'unknown' * - /journal?tab=all โ†’ source: 'journal', tab: 'all' - * - /topic/ai/popular โ†’ source: 'topic', tab: 'popular' - * - /topic/ai/latest โ†’ source: 'topic', tab: 'latest' * - /fund/needs-funding โ†’ source: 'fund', tab: 'needs-funding' * - /author/153397 โ†’ source: 'author', tab: 'contributions' * - /list/123 โ†’ source: 'list', tab: '123' @@ -53,7 +51,6 @@ function isValidFeedSource(source: string): source is FeedSource { 'peer-review', 'fund', 'journal', - 'topic', 'author', 'list', ]; @@ -82,30 +79,26 @@ export function useFeedSource(): FeedSourceInfo { // Extract source from first path segment const source = pathSegments[0] || 'home'; - const isTopicTab = source === 'topic'; const isAuthorTab = source === 'author'; const isListTab = source === 'list'; const feedSource = toFeedSource(source); - // Extract tab: 1) query param, 2) third path segment for topic pages, 3) second path segment, 4) unknown + // Extract tab: 1) query param, 2) second path segment, 3) unknown let tab: string; const queryTab = searchParams.get('tab'); const pathTab = pathSegments[1]; - const topicTab = pathSegments[2]; if (queryTab) { tab = queryTab; - } else if (isTopicTab && topicTab) { - tab = topicTab; } else if (isListTab && pathTab) { // For list pages, use the list ID (second path segment) as tab tab = pathTab; } else if (isAuthorTab) { // For author pages, default to 'contributions' when no query param tab = 'contributions'; - } else if (pathTab && !isTopicTab && !isAuthorTab && !isListTab) { + } else if (pathTab && !isAuthorTab && !isListTab) { tab = pathTab; } else { tab = 'unknown'; diff --git a/hooks/useHub.ts b/hooks/useHub.ts deleted file mode 100644 index a8efe9b46..000000000 --- a/hooks/useHub.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Hub, HubService } from '@/services/hub.service'; - -export function useHub(slug: string | null) { - const [hub, setHub] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!slug) return; - - const fetchHub = async () => { - setIsLoading(true); - setError(null); - - try { - const hubData = await HubService.getHubBySlug(slug); - setHub(hubData); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch hub')); - console.error('Error fetching hub:', err); - } finally { - setIsLoading(false); - } - }; - - fetchHub(); - }, [slug]); - - return { hub, isLoading, error }; -} diff --git a/lib/metadata-helpers.ts b/lib/metadata-helpers.ts index e4c218486..f1e9ce544 100644 --- a/lib/metadata-helpers.ts +++ b/lib/metadata-helpers.ts @@ -104,21 +104,3 @@ export function getReferralMetadata({ }, }; } - -export function getBrowsePageMetadata() { - return buildOpenGraphMetadata({ - title: 'Browse Topics - ResearchHub', - description: - 'Explore research topics and communities on ResearchHub. Join discussions, follow your interests, and contribute to scientific advancement.', - url: '/browse', - type: 'website', - tags: [ - 'Research Topics', - 'Scientific Communities', - 'ResearchHub', - 'Academic Topics', - 'Science Categories', - ], - section: 'Browse', - }); -} diff --git a/next.config.js b/next.config.js index 3460650d5..440895ad4 100644 --- a/next.config.js +++ b/next.config.js @@ -19,9 +19,15 @@ const nextConfig = { }, { source: '/journal/:slug', - destination: '/topic/:slug', + destination: '/', permanent: true, }, + // Topic (hub) pages and the topic browser are retired. + ...['/topic/:path*', '/browse'].map((source) => ({ + source, + destination: '/', + permanent: true, + })), { source: '/trending', destination: '/', diff --git a/services/follow.service.ts b/services/follow.service.ts deleted file mode 100644 index 8fa1308d9..000000000 --- a/services/follow.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ApiClient } from './client'; -import { FollowResponse, FollowedObject, transformFollowedObject } from '@/types/follow'; - -interface FollowMultipleResponse { - followed: Array<{ - id: number; - name: string; - slug?: string; - }>; - already_following: Array<{ - id: number; - name: string; - }>; - not_found: number[]; -} - -export class FollowService { - private static readonly BASE_PATH = '/api/hub'; - - /** - * Get all objects that the user is following - * @returns Array of followed objects with their data - */ - static async getFollowedObjects(): Promise { - const response = await ApiClient.get(`${this.BASE_PATH}/following/`); - return response.map(transformFollowedObject); - } - - /** - * Get only followed topics/hubs with their metadata - * @returns Array of FollowedObject containing Topics - */ - static async getFollowedTopics(): Promise { - const followedObjects = await this.getFollowedObjects(); - - // Filter for hub/topic objects that have valid topic data - return followedObjects.filter((obj) => obj.type === 'HUB' && obj.data !== null); - } - - /** - * Follow multiple hubs/topics at once - * @param hubIds Array of hub IDs to follow - * @returns Response with followed, already following, and not found items - */ - static async followMultipleHubs(hubIds: number[]): Promise { - return await ApiClient.post(`${this.BASE_PATH}/follow_multiple/`, { - ids: hubIds, - }); - } -} diff --git a/services/hub.service.ts b/services/hub.service.ts index b5528c7a0..848c83280 100644 --- a/services/hub.service.ts +++ b/services/hub.service.ts @@ -1,46 +1,8 @@ -import { Topic, transformTopicSuggestions, transformTopic } from '@/types/topic'; +import { Topic, transformTopicSuggestions } from '@/types/topic'; import { ApiClient } from './client'; -interface HubResponse { - id: number; - name: string; - slug: string; - hub_image?: string | null; - description?: string; - namespace?: string; - category?: string; - discussion_count?: number; - paper_count?: number; - subscriber_count?: number; - is_locked?: boolean; - is_removed?: boolean; - editor_permission_groups?: any[]; -} - -interface HubDetailResponse { - count: number; - next: string | null; - previous: string | null; - results: HubResponse[]; -} - -interface PrimaryHubsResponse { - count: number; - results: HubResponse[]; -} - -export interface Hub { - id: number; - name: string; - slug: string; - imageUrl?: string; - description?: string; -} - export class HubService { - private static readonly BASE_PATH = '/api/hub'; private static readonly SUGGEST_PATH = '/api/search/hubs/suggest'; - private static readonly PRIMARY_HUBS_PATH = '/api/hub/primary_only'; static async suggestTopics(query: string, limit?: number): Promise { const params = new URLSearchParams({ @@ -56,29 +18,4 @@ export class HubService { return transformTopicSuggestions(response); } - - static async followHub(hubId: number): Promise { - await ApiClient.post(`${this.BASE_PATH}/${hubId}/follow/`); - } - - static async unfollowHub(hubId: number): Promise { - await ApiClient.post(`${this.BASE_PATH}/${hubId}/unfollow/`); - } - - static async getHubBySlug(slug: string): Promise { - const response = await ApiClient.get( - `${this.BASE_PATH}?slug=${encodeURIComponent(slug)}` - ); - - if (!response.results.length) { - throw new Error(`Hub with slug "${slug}" not found`); - } - - return transformTopic(response.results[0]); - } - - static async getPrimaryHubs(): Promise { - const response = await ApiClient.getPublic(this.PRIMARY_HUBS_PATH); - return response.results.map(transformTopic); - } } diff --git a/types/analytics.ts b/types/analytics.ts index 50a0f1812..142b9f4f0 100644 --- a/types/analytics.ts +++ b/types/analytics.ts @@ -32,7 +32,6 @@ export type FeedSource = | 'peer-review' | 'fund' | 'journal' - | 'topic' | 'author' | 'list' | 'unknown'; diff --git a/types/follow.ts b/types/follow.ts deleted file mode 100644 index ee225e527..000000000 --- a/types/follow.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createTransformer } from './transformer'; -import { Topic, transformTopic } from './topic'; - -export interface FollowResponse { - id: number; - type: 'HUB' | 'PAPER' | 'POST' | 'BOUNTY' | string; - object_id: number; - followed_object: any; // The actual hub, paper, post data - created_date: string; - updated_date: string; -} - -export type FollowedObject = { - id: number; - type: string; - objectId: number; - createdDate: string; - updatedDate: string; - data: Topic | any; // Can be Topic, Paper, etc. based on type -}; - -export const transformFollowedObject = createTransformer( - (raw: FollowResponse) => { - let transformedData: any = null; - - // Transform based on the type - if (raw.type === 'HUB' && raw.followed_object) { - // For hub objects, use the topic transformer on the followed_object - transformedData = transformTopic(raw.followed_object); - } else if (raw.followed_object) { - // For other types (papers, etc.), we can add transformers later - transformedData = raw.followed_object; - } - - return { - id: raw.id, - type: raw.type, - objectId: raw.object_id, - createdDate: raw.created_date, - updatedDate: raw.updated_date, - data: transformedData, - }; - } -); diff --git a/types/search.ts b/types/search.ts index c87ea8f29..1ed8cb5c6 100644 --- a/types/search.ts +++ b/types/search.ts @@ -1,12 +1,11 @@ import { createTransformer } from './transformer'; -import { buildWorkUrl, buildAuthorUrl, buildTopicUrl } from '@/utils/url'; +import { buildWorkUrl, buildAuthorUrl } from '@/utils/url'; import { mapApiDocumentTypeToClientType } from '@/utils/contentTypeMapping'; import { AuthorProfile, extractHeadline } from './authorProfile'; import { ID } from './root'; -import { transformTopic } from './topic'; import { ContentType } from './work'; -export type EntityType = 'user' | 'paper' | 'author' | 'post' | 'hub'; +export type EntityType = 'user' | 'paper' | 'author' | 'post'; export interface BaseSuggestion { entityType: EntityType; @@ -49,17 +48,7 @@ export interface PostSuggestion extends BaseSuggestion { documentType?: string; } -export interface TopicSuggestion extends BaseSuggestion { - entityType: 'hub'; - displayName: string; - id: number; - slug: string; - imageUrl?: string; - description?: string; - paperCount?: number; -} - -export type SearchSuggestion = WorkSuggestion | UserSuggestion | PostSuggestion | TopicSuggestion; +export type SearchSuggestion = WorkSuggestion | UserSuggestion | PostSuggestion; export const transformSearchSuggestion = createTransformer((raw: any) => { try { @@ -174,36 +163,6 @@ export const transformSearchSuggestion = createTransformer { - if (!str) return ''; - return str - .toLowerCase() - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -}; - export const pluralizeSuffix = (count: number): string => (count === 1 ? '' : 's'); /** diff --git a/utils/url.ts b/utils/url.ts index 3b2652d32..0623bc4a4 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -382,10 +382,3 @@ export const buildWorkUrl = ({ export const buildAuthorUrl = (id: string | number) => { return `/author/${id}`; }; - -/** - * Builds a topic URL from a slug - */ -export const buildTopicUrl = (slug: string) => { - return `/topic/${slug}`; -};