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 = ({
- {(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 ? (
+
+ ) : (
+ {displayName}
+ )}
+
+ );
return (
-
-
- {isRHJournal ? (
- <>
-
- RH Journal
- >
- ) : logo ? (
-
- ) : journal.imageUrl ? (
-
- ) : (
- {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}`;
-};