From a4cd3cb87c2e1a5aa4eb9ddf2b20ac1fdbdbcd9a Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 11:54:23 -0600 Subject: [PATCH 1/8] updates search result cards to match redesign in the feed, resolves few merge issues --- app/search/SearchPageContent.tsx | 3 +- components/Feed/BaseFeedItem.tsx | 50 +++++------------ components/Feed/FeedContent.tsx | 3 + components/Feed/FeedEntryItem.tsx | 3 + components/Feed/FeedItemActions.tsx | 73 ++++++++++++++++--------- components/Feed/items/FeedItemPaper.tsx | 5 +- components/Search/SearchModal.tsx | 4 +- services/search.service.ts | 27 ++++++++- types/search.ts | 10 ++++ 9 files changed, 111 insertions(+), 67 deletions(-) diff --git a/app/search/SearchPageContent.tsx b/app/search/SearchPageContent.tsx index f6ebfd12..09c74566 100644 --- a/app/search/SearchPageContent.tsx +++ b/app/search/SearchPageContent.tsx @@ -165,7 +165,8 @@ export function SearchPageContent({ searchParams }: SearchPageContentProps) { loadMore={loadMore} showGrantHeaders={true} showReadMoreCTA={true} - hideActions={true} + hideActions={false} + showOnlyBookmark={true} noEntriesElement={ = ({ className, maxLength = 200, }) => { - const [isExpanded, setIsExpanded] = useState(false); - const isTextTruncated = content && content.length > maxLength; - - const handleToggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded(!isExpanded); - }; - - // If we have highlighted HTML, render it (already truncated by backend) + // If we have highlighted HTML (from search service), render it as-is + // The search service is responsible for extending snippets to appropriate length if (highlightedContent) { return (
@@ -183,29 +175,15 @@ export const ContentSection: FC = ({ ); } - // Default: render truncated plain text - return ( - - ); + // Default: render truncated plain text for non-search results + if (content) { + return ( +
+

{truncateText(content, maxLength)}

+
+ ); + } + return null; }; export const ImageSection: FC = ({ @@ -284,6 +262,7 @@ export const BaseFeedItem: FC = ({ href, className, showActions = true, + showOnlyBookmark = false, showTooltips = true, maxLength, showHeader = true, @@ -443,6 +422,7 @@ export const BaseFeedItem: FC = ({ onFeedItemClick={onFeedItemClick} bounties={showBountyInfo ? undefined : content.bounties} hideReportButton={hideReportButton} + showOnlyBookmark={showOnlyBookmark} />
)} diff --git a/components/Feed/FeedContent.tsx b/components/Feed/FeedContent.tsx index 28daa100..46bded06 100644 --- a/components/Feed/FeedContent.tsx +++ b/components/Feed/FeedContent.tsx @@ -30,6 +30,7 @@ interface FeedContentProps { activeTab?: FeedTab | FundingTab | TabType | string; showBountyFooter?: boolean; hideActions?: boolean; + showOnlyBookmark?: boolean; isLoadingMore?: boolean; noEntriesElement?: ReactNode; maxLength?: number; @@ -58,6 +59,7 @@ export const FeedContent: FC = ({ activeTab, showBountyFooter = true, hideActions = false, + showOnlyBookmark = false, isLoadingMore = false, noEntriesElement, maxLength, @@ -155,6 +157,7 @@ export const FeedContent: FC = ({ index={index} showBountyFooter={showBountyFooter} hideActions={hideActions} + showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} showGrantHeaders={showGrantHeaders} showFundraiseHeaders={showFundraiseHeaders} diff --git a/components/Feed/FeedEntryItem.tsx b/components/Feed/FeedEntryItem.tsx index 5ead2f26..4b1ec0b1 100644 --- a/components/Feed/FeedEntryItem.tsx +++ b/components/Feed/FeedEntryItem.tsx @@ -31,6 +31,7 @@ interface FeedEntryItemProps { index: number; showBountyFooter?: boolean; hideActions?: boolean; + showOnlyBookmark?: boolean; maxLength?: number; showGrantHeaders?: boolean; showFundraiseHeaders?: boolean; @@ -51,6 +52,7 @@ export const FeedEntryItem: FC = ({ index, showBountyFooter = true, hideActions = false, + showOnlyBookmark = false, maxLength, showGrantHeaders = true, showFundraiseHeaders = true, @@ -204,6 +206,7 @@ export const FeedEntryItem: FC = ({ entry={entry} href={href} showActions={!hideActions} + showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} onFeedItemClick={handleFeedItemClick} highlights={highlights} diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index d95071a4..e81fb028 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -166,6 +166,7 @@ interface FeedItemActionsProps { relatedDocumentUnifiedDocumentId?: string; showPeerReviews?: boolean; onFeedItemClick?: () => void; + showOnlyBookmark?: boolean; // Show only the bookmark button (for search results) } // Define interface for avatar items used in local state @@ -198,6 +199,7 @@ export const FeedItemActions: FC = ({ relatedDocumentUnifiedDocumentId, showPeerReviews = true, onFeedItemClick, + showOnlyBookmark = false, }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { showUSD } = useCurrencyPreference(); @@ -372,6 +374,49 @@ export const FeedItemActions: FC = ({ const showInlineReviews = showPeerReviews && reviews.length > 0; const showInlineBounties = hasOpenBounties; + // Check if bookmark button should be shown + const canShowBookmark = + userListsEnabled && + relatedDocumentUnifiedDocumentId && + feedContentType !== 'COMMENT' && + feedContentType !== 'BOUNTY' && + feedContentType !== 'APPLICATION'; + + // Reusable bookmark button element + const bookmarkButton = canShowBookmark && ( + + ); + + // If showOnlyBookmark, render a minimal version with just the bookmark button + if (showOnlyBookmark) { + return ( + <> +
{bookmarkButton}
+ {userListsEnabled && relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( + + )} + + ); + } + return ( <>
@@ -505,32 +550,8 @@ export const FeedItemActions: FC = ({
{rightSideActionButton} - {/* Show "Add to List" button in right section when hideReportButton is true */} - {userListsEnabled && - relatedDocumentUnifiedDocumentId && - feedContentType !== 'COMMENT' && - feedContentType !== 'BOUNTY' && - feedContentType !== 'APPLICATION' && - showPeerReviews && ( - - )} + {/* Show "Add to List" button in right section */} + {showPeerReviews && bookmarkButton} {(!hideReportButton || menuItems.length > 0) && ( void; highlights?: Highlight[]; @@ -40,6 +41,7 @@ export const FeedItemPaper: FC = ({ href, showTooltips = true, showActions = true, + showOnlyBookmark = false, maxLength, onFeedItemClick, highlights, @@ -79,6 +81,7 @@ export const FeedItemPaper: FC = ({ entry={entry} href={paperPageUrl} showActions={showActions} + showOnlyBookmark={showOnlyBookmark} showHeader={false} showTooltips={showTooltips} customActionText={actionText} diff --git a/components/Search/SearchModal.tsx b/components/Search/SearchModal.tsx index 35df7937..8a690582 100644 --- a/components/Search/SearchModal.tsx +++ b/components/Search/SearchModal.tsx @@ -160,10 +160,10 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { inputRef.current?.select(); }} onKeyDown={(e) => { - if (e.key === 'Enter' && e.shiftKey && query.trim()) { + if (e.key === 'Enter' && query.trim()) { e.preventDefault(); navigatingToSearchRef.current = true; - router.push(`/search?debug&q=${encodeURIComponent(query.trim())}`); + router.push(`/search?q=${encodeURIComponent(query.trim())}`); onClose(); } }} diff --git a/services/search.service.ts b/services/search.service.ts index 8366b0d4..605f10ab 100644 --- a/services/search.service.ts +++ b/services/search.service.ts @@ -456,8 +456,31 @@ export class SearchService { last_name: author.last_name || '', profile_image: '', })), - hub: doc.hubs && doc.hubs.length > 0 ? doc.hubs[0] : null, - journal: null, + hub: doc.hubs?.[0] || null, + // Pass category and subcategory from first two hubs for badge display + category: doc.hubs?.[0] || null, + subcategory: doc.hubs?.[1] || null, + // Handle journal as either a string (legacy) or object (new format) + journal: doc.journal + ? typeof doc.journal === 'string' + ? { + // Legacy format: journal is just a string name + id: 0, + name: doc.journal, + slug: doc.journal.toLowerCase().replace(/\s+/g, '-'), + imageUrl: null, + } + : { + // New format: journal is an object + id: doc.journal?.id || 0, + name: doc.journal?.name || '', + slug: + doc.journal?.slug || + doc.journal?.name?.toLowerCase().replace(/\s+/g, '-') || + null, + imageUrl: doc.journal?.image_url || null, + } + : null, doi: doc.doi, citations: doc.citations || 0, score: doc.score || 0, diff --git a/types/search.ts b/types/search.ts index 599d186f..9286dbb7 100644 --- a/types/search.ts +++ b/types/search.ts @@ -333,6 +333,16 @@ export interface ApiDocumentSearchResult { is_open_access: boolean | null; slug: string | null; document_type: string | null; // 'GRANT', etc. + journal?: + | string // Legacy format: just the journal name + | { + // New format: full journal object + id: number; + name: string; + slug?: string; + image_url?: string; + } + | null; } export interface PersonSearchResult { From 6ca77b0d004a006c322511626c698b8637222e7e Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 12:02:22 -0600 Subject: [PATCH 2/8] reverting the readmore that was accidentally removed --- components/Feed/BaseFeedItem.tsx | 35 ++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/components/Feed/BaseFeedItem.tsx b/components/Feed/BaseFeedItem.tsx index 96ab7a60..b06ae5b6 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC, ReactNode } from 'react'; +import { FC, ReactNode, useState } from 'react'; import { FeedContentType, FeedEntry, @@ -19,6 +19,8 @@ import { BountyInfoSummary } from '@/components/Bounty/BountyInfoSummary'; import { useRouter } from 'next/navigation'; import { BountyInfo } from '../Bounty/BountyInfo'; import { sanitizeHighlightHtml } from '@/components/Search/lib/htmlSanitizer'; +import { Button } from '@/components/ui/Button'; +import { ChevronDown } from 'lucide-react'; // Base interfaces for the modular components export interface BaseFeedItemProps { @@ -161,6 +163,14 @@ export const ContentSection: FC = ({ className, maxLength = 200, }) => { + const [isExpanded, setIsExpanded] = useState(false); + const isTextTruncated = content && content.length > maxLength; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + // If we have highlighted HTML (from search service), render it as-is // The search service is responsible for extending snippets to appropriate length if (highlightedContent) { @@ -175,11 +185,28 @@ export const ContentSection: FC = ({ ); } - // Default: render truncated plain text for non-search results + // Default: render truncated plain text with expand/collapse for non-search results if (content) { return ( -
-

{truncateText(content, maxLength)}

+ ); } From b781313cb32768630ae344cdd11e4c09adee6707 Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 12:06:15 -0600 Subject: [PATCH 3/8] adding read more to search results --- components/Feed/FeedItemAbstractSection.tsx | 45 ++++++++++++++++----- services/search.service.ts | 2 +- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/components/Feed/FeedItemAbstractSection.tsx b/components/Feed/FeedItemAbstractSection.tsx index 4c75a8cc..be9aee6e 100644 --- a/components/Feed/FeedItemAbstractSection.tsx +++ b/components/Feed/FeedItemAbstractSection.tsx @@ -41,15 +41,42 @@ export const FeedItemAbstractSection: FC = ({ // If we have highlighted HTML, render it (search results) if (highlightedContent) { + // Check if full content is longer than highlighted snippet (worth expanding) + const hasMoreContent = + content && content.length > highlightedContent.replace(/<[^>]*>/g, '').length + 50; + return (
- {/* Desktop: Show content directly */} + {/* Desktop: Show content with expand/collapse */}
-

+ {isDesktopExpanded ? ( + // Show full plain text content when expanded +

{content}

+ ) : ( + // Show highlighted snippet when collapsed +

+ )} + {hasMoreContent && ( + + )}

{/* Mobile: Show toggle CTA */} @@ -71,11 +98,7 @@ export const FeedItemAbstractSection: FC = ({ {isMobileExpanded && (
-

+

{content}

)}
diff --git a/services/search.service.ts b/services/search.service.ts index 605f10ab..62c104e0 100644 --- a/services/search.service.ts +++ b/services/search.service.ts @@ -18,7 +18,7 @@ import { highlightSearchTerms, hasHighlights } from '@/components/Search/lib/sea import { stripHtml } from '@/utils/stringUtils'; // Constants for search result snippet extension -const SEARCH_RESULT_MAX_LENGTH = 300; // Maximum length for extended search result snippets +const SEARCH_RESULT_MAX_LENGTH = 500; // Maximum length for extended search result snippets export interface InstitutionResponse { id: number; From 683122f97a53e93cb5c2803a15aa9c418fdb394a Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 12:20:58 -0600 Subject: [PATCH 4/8] refactor code duplication in the feedItemAbstractSection, updates the stripHTML tags to avoid reDoS exploit --- components/Feed/FeedItemAbstractSection.tsx | 172 ++++++++------------ utils/stringUtils.ts | 29 +++- 2 files changed, 95 insertions(+), 106 deletions(-) diff --git a/components/Feed/FeedItemAbstractSection.tsx b/components/Feed/FeedItemAbstractSection.tsx index be9aee6e..fa431277 100644 --- a/components/Feed/FeedItemAbstractSection.tsx +++ b/components/Feed/FeedItemAbstractSection.tsx @@ -2,7 +2,7 @@ import { FC, useState } from 'react'; import { cn } from '@/utils/styles'; -import { truncateText } from '@/utils/stringUtils'; +import { truncateText, stripHtml } from '@/utils/stringUtils'; import { Button } from '@/components/ui/Button'; import { ChevronDown } from 'lucide-react'; import { sanitizeHighlightHtml } from '@/components/Search/lib/htmlSanitizer'; @@ -15,6 +15,40 @@ export interface FeedItemAbstractSectionProps { mobileLabel?: string; } +// Reusable expand/collapse button component +const ExpandButton: FC<{ + isExpanded: boolean; + onClick: (e: React.MouseEvent) => void; + variant?: 'desktop' | 'mobile'; + mobileLabel?: string; +}> = ({ isExpanded, onClick, variant = 'desktop', mobileLabel = 'Read abstract' }) => { + const isDesktop = variant === 'desktop'; + + return ( + + ); +}; + export const FeedItemAbstractSection: FC = ({ content, highlightedContent, @@ -25,8 +59,6 @@ export const FeedItemAbstractSection: FC = ({ const [isDesktopExpanded, setIsDesktopExpanded] = useState(false); const [isMobileExpanded, setIsMobileExpanded] = useState(false); - const isTextTruncated = content && content.length > maxLength; - const handleDesktopToggle = (e: React.MouseEvent) => { e.stopPropagation(); setIsDesktopExpanded(!isDesktopExpanded); @@ -39,118 +71,56 @@ export const FeedItemAbstractSection: FC = ({ if (!content) return null; - // If we have highlighted HTML, render it (search results) - if (highlightedContent) { - // Check if full content is longer than highlighted snippet (worth expanding) - const hasMoreContent = - content && content.length > highlightedContent.replace(/<[^>]*>/g, '').length + 50; + // Determine if content is truncatable + const isTextTruncated = content.length > maxLength; - return ( -
- {/* Desktop: Show content with expand/collapse */} -
- {isDesktopExpanded ? ( - // Show full plain text content when expanded -

{content}

- ) : ( - // Show highlighted snippet when collapsed -

- )} - {hasMoreContent && ( - - )} -

+ // For highlighted content, check if full content is meaningfully longer + const hasMoreContent = highlightedContent + ? content.length > stripHtml(highlightedContent).length + 50 + : isTextTruncated; - {/* Mobile: Show toggle CTA */} -
- - {isMobileExpanded && ( -
-

{content}

-
- )} -
-
- ); - } + // Text color varies based on whether we have highlighted content + const textColorClass = highlightedContent ? 'text-gray-700' : 'text-gray-900'; + + // Get the display content for collapsed state + const getCollapsedContent = () => { + if (highlightedContent) { + return ( +

+ ); + } + return

{truncateText(content, maxLength)}

; + }; - // Default: render plain text with truncation return (
{/* Desktop: Show content with expand/collapse */} -
-

{isDesktopExpanded ? content : truncateText(content, maxLength)}

- {isTextTruncated && ( - + variant="desktop" + /> )}
{/* Mobile: Show toggle CTA */}
- + variant="mobile" + mobileLabel={mobileLabel} + /> {isMobileExpanded && ( -
-

{truncateText(content, maxLength)}

+
+

{highlightedContent ? content : truncateText(content, maxLength)}

)}
diff --git a/utils/stringUtils.ts b/utils/stringUtils.ts index 16f93443..7e076684 100644 --- a/utils/stringUtils.ts +++ b/utils/stringUtils.ts @@ -15,16 +15,35 @@ export const truncateText = (text: string, maxLength: number = 200): string => { }; /** - * Strips HTML tags from a string + * Strips HTML tags from a string using an iterative approach (safe from ReDoS) * @param html The HTML string to strip * @returns The plain text without HTML tags */ export const stripHtml = (html: string): string => { if (!html) return ''; - return html - .replace(/<[^>]+>/g, '') // Remove HTML tags - .replace(/ /g, ' ') // Replace   with spaces - .replace(/\s+/g, ' ') // Replace multiple whitespace with single space + + let result = ''; + let inTag = false; + + for (let i = 0; i < html.length; i++) { + const char = html[i]; + if (char === '<') { + inTag = true; + } else if (char === '>') { + inTag = false; + } else if (!inTag) { + result += char; + } + } + + // Clean up whitespace and HTML entities + return result + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\s+/g, ' ') .trim(); }; From 9420ec3ac3ceddf644bb0a30ff35d6ae94804b29 Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 12:26:43 -0600 Subject: [PATCH 5/8] addressing remaining sonar concerns --- components/Feed/BaseFeedItem.tsx | 2 +- components/Feed/FeedItemAbstractSection.tsx | 16 +++--- services/search.service.ts | 55 +++++++++++++-------- utils/stringUtils.ts | 13 +++-- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/components/Feed/BaseFeedItem.tsx b/components/Feed/BaseFeedItem.tsx index b06ae5b6..c9d04651 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -12,7 +12,7 @@ import { FeedItemActions } from '@/components/Feed/FeedItemActions'; import { CardWrapper } from './CardWrapper'; import { cn } from '@/utils/styles'; import Image from 'next/image'; -import { stripHtml, truncateText } from '@/utils/stringUtils'; +import { truncateText } from '@/utils/stringUtils'; import { TopicAndJournalBadge } from '@/components/ui/TopicAndJournalBadge'; import { useNavigation } from '@/contexts/NavigationContext'; import { BountyInfoSummary } from '@/components/Bounty/BountyInfoSummary'; diff --git a/components/Feed/FeedItemAbstractSection.tsx b/components/Feed/FeedItemAbstractSection.tsx index fa431277..7de0f4fe 100644 --- a/components/Feed/FeedItemAbstractSection.tsx +++ b/components/Feed/FeedItemAbstractSection.tsx @@ -15,6 +15,14 @@ export interface FeedItemAbstractSectionProps { mobileLabel?: string; } +// Helper to get button label without nested ternary +const getButtonLabel = (isDesktop: boolean, isExpanded: boolean, mobileLabel: string): string => { + if (isDesktop) { + return isExpanded ? 'Show less' : 'Read more'; + } + return isExpanded ? 'Hide abstract' : mobileLabel; +}; + // Reusable expand/collapse button component const ExpandButton: FC<{ isExpanded: boolean; @@ -34,13 +42,7 @@ const ExpandButton: FC<{ isDesktop ? 'gap-0.5 mt-1 text-blue-500' : 'gap-1 text-blue-600 hover:text-blue-700' )} > - {isDesktop - ? isExpanded - ? 'Show less' - : 'Read more' - : isExpanded - ? 'Hide abstract' - : mobileLabel} + {getButtonLabel(isDesktop, isExpanded, mobileLabel)} { let result = ''; let inTag = false; - for (let i = 0; i < html.length; i++) { - const char = html[i]; + for (const char of html) { if (char === '<') { inTag = true; } else if (char === '>') { @@ -38,11 +37,11 @@ export const stripHtml = (html: string): string => { // Clean up whitespace and HTML entities return result - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') .replace(/\s+/g, ' ') .trim(); }; From 0f46bba1f64af863b202b2e9da4c8012fb855ce5 Mon Sep 17 00:00:00 2001 From: kristian magda Date: Wed, 10 Dec 2025 14:15:47 -0600 Subject: [PATCH 6/8] refactor to remove the prop drilling through intermediate components, adds FeedViewContext --- app/search/SearchPageContent.tsx | 42 +++++++++++++------------ components/Feed/BaseFeedItem.tsx | 3 -- components/Feed/FeedContent.tsx | 3 -- components/Feed/FeedEntryItem.tsx | 3 -- components/Feed/FeedItemActions.tsx | 7 +++-- components/Feed/items/FeedItemPaper.tsx | 3 -- contexts/FeedViewContext.tsx | 37 ++++++++++++++++++++++ 7 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 contexts/FeedViewContext.tsx diff --git a/app/search/SearchPageContent.tsx b/app/search/SearchPageContent.tsx index 09c74566..7b7eb6ff 100644 --- a/app/search/SearchPageContent.tsx +++ b/app/search/SearchPageContent.tsx @@ -9,6 +9,7 @@ import { PageLayout } from '@/app/layouts/PageLayout'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import { Search as SearchIcon } from 'lucide-react'; import { FeedContent } from '@/components/Feed/FeedContent'; +import { FeedViewProvider } from '@/contexts/FeedViewContext'; interface SearchPageContentProps { readonly searchParams: { @@ -157,26 +158,27 @@ export function SearchPageContent({ searchParams }: SearchPageContentProps) { {/* Use FeedContent for consistent rendering and infinite scroll */} {hasSearched && ( - {}} - /> - } - /> + + {}} + /> + } + /> + )}
diff --git a/components/Feed/BaseFeedItem.tsx b/components/Feed/BaseFeedItem.tsx index c9d04651..0dcc0ac2 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -28,7 +28,6 @@ export interface BaseFeedItemProps { href?: string; className?: string; showActions?: boolean; - showOnlyBookmark?: boolean; showTooltips?: boolean; maxLength?: number; showHeader?: boolean; @@ -289,7 +288,6 @@ export const BaseFeedItem: FC = ({ href, className, showActions = true, - showOnlyBookmark = false, showTooltips = true, maxLength, showHeader = true, @@ -449,7 +447,6 @@ export const BaseFeedItem: FC = ({ onFeedItemClick={onFeedItemClick} bounties={showBountyInfo ? undefined : content.bounties} hideReportButton={hideReportButton} - showOnlyBookmark={showOnlyBookmark} />
)} diff --git a/components/Feed/FeedContent.tsx b/components/Feed/FeedContent.tsx index 46bded06..28daa100 100644 --- a/components/Feed/FeedContent.tsx +++ b/components/Feed/FeedContent.tsx @@ -30,7 +30,6 @@ interface FeedContentProps { activeTab?: FeedTab | FundingTab | TabType | string; showBountyFooter?: boolean; hideActions?: boolean; - showOnlyBookmark?: boolean; isLoadingMore?: boolean; noEntriesElement?: ReactNode; maxLength?: number; @@ -59,7 +58,6 @@ export const FeedContent: FC = ({ activeTab, showBountyFooter = true, hideActions = false, - showOnlyBookmark = false, isLoadingMore = false, noEntriesElement, maxLength, @@ -157,7 +155,6 @@ export const FeedContent: FC = ({ index={index} showBountyFooter={showBountyFooter} hideActions={hideActions} - showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} showGrantHeaders={showGrantHeaders} showFundraiseHeaders={showFundraiseHeaders} diff --git a/components/Feed/FeedEntryItem.tsx b/components/Feed/FeedEntryItem.tsx index 4b1ec0b1..5ead2f26 100644 --- a/components/Feed/FeedEntryItem.tsx +++ b/components/Feed/FeedEntryItem.tsx @@ -31,7 +31,6 @@ interface FeedEntryItemProps { index: number; showBountyFooter?: boolean; hideActions?: boolean; - showOnlyBookmark?: boolean; maxLength?: number; showGrantHeaders?: boolean; showFundraiseHeaders?: boolean; @@ -52,7 +51,6 @@ export const FeedEntryItem: FC = ({ index, showBountyFooter = true, hideActions = false, - showOnlyBookmark = false, maxLength, showGrantHeaders = true, showFundraiseHeaders = true, @@ -206,7 +204,6 @@ export const FeedEntryItem: FC = ({ entry={entry} href={href} showActions={!hideActions} - showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} onFeedItemClick={handleFeedItemClick} highlights={highlights} diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index e81fb028..95bc4d1e 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -32,6 +32,7 @@ import { useUserListsEnabled } from '@/components/UserList/lib/hooks/useUserList import { PeerReviewTooltip } from '@/components/tooltips/PeerReviewTooltip'; import { BountyTooltip } from '@/components/tooltips/BountyTooltip'; import { useIsTouchDevice } from '@/hooks/useIsTouchDevice'; +import { useFeedView } from '@/contexts/FeedViewContext'; // Basic media query hook (can be moved to a utility file later) const useMediaQuery = (query: string): boolean => { @@ -166,7 +167,6 @@ interface FeedItemActionsProps { relatedDocumentUnifiedDocumentId?: string; showPeerReviews?: boolean; onFeedItemClick?: () => void; - showOnlyBookmark?: boolean; // Show only the bookmark button (for search results) } // Define interface for avatar items used in local state @@ -199,9 +199,12 @@ export const FeedItemActions: FC = ({ relatedDocumentUnifiedDocumentId, showPeerReviews = true, onFeedItemClick, - showOnlyBookmark = false, }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); + const feedView = useFeedView(); + + // UI decisions based on feed view context + const showOnlyBookmark = feedView === 'search'; const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const [localVoteCount, setLocalVoteCount] = useState(metrics?.votes || 0); diff --git a/components/Feed/items/FeedItemPaper.tsx b/components/Feed/items/FeedItemPaper.tsx index d06ff105..b6cf86dd 100644 --- a/components/Feed/items/FeedItemPaper.tsx +++ b/components/Feed/items/FeedItemPaper.tsx @@ -26,7 +26,6 @@ interface FeedItemPaperProps { href?: string; showTooltips?: boolean; showActions?: boolean; - showOnlyBookmark?: boolean; maxLength?: number; onFeedItemClick?: () => void; highlights?: Highlight[]; @@ -41,7 +40,6 @@ export const FeedItemPaper: FC = ({ href, showTooltips = true, showActions = true, - showOnlyBookmark = false, maxLength, onFeedItemClick, highlights, @@ -81,7 +79,6 @@ export const FeedItemPaper: FC = ({ entry={entry} href={paperPageUrl} showActions={showActions} - showOnlyBookmark={showOnlyBookmark} showHeader={false} showTooltips={showTooltips} customActionText={actionText} diff --git a/contexts/FeedViewContext.tsx b/contexts/FeedViewContext.tsx new file mode 100644 index 00000000..9c366f07 --- /dev/null +++ b/contexts/FeedViewContext.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { createContext, useContext, ReactNode, FC } from 'react'; + +/** + * Represents the different views where feed items can be rendered. + * This context helps components make UI decisions based on where they're displayed. + */ +export type FeedView = 'feed' | 'search' | 'profile' | 'lists'; + +const FeedViewContext = createContext('feed'); + +/** + * Hook to access the current feed view context. + * Returns 'feed' by default if not wrapped in a provider. + */ +export const useFeedView = (): FeedView => useContext(FeedViewContext); + +interface FeedViewProviderProps { + value: FeedView; + children: ReactNode; +} + +/** + * Provider component for setting the feed view context. + * Wrap feed content with this provider to specify the view type. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export const FeedViewProvider: FC = ({ value, children }) => { + return {children}; +}; From d29a76fabb8298493688fbb7a7b8c065925d52f1 Mon Sep 17 00:00:00 2001 From: kristian magda Date: Fri, 12 Dec 2025 12:17:34 -0600 Subject: [PATCH 7/8] resolving merge conflicts --- app/search/SearchPageContent.tsx | 1 + components/Feed/BaseFeedItem.tsx | 3 +++ components/Feed/FeedContent.tsx | 3 +++ components/Feed/FeedEntryItem.tsx | 3 +++ components/Feed/FeedItemActions.tsx | 12 +++++++----- components/Feed/items/FeedItemPaper.tsx | 3 +++ 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/search/SearchPageContent.tsx b/app/search/SearchPageContent.tsx index 7b7eb6ff..8d43d304 100644 --- a/app/search/SearchPageContent.tsx +++ b/app/search/SearchPageContent.tsx @@ -168,6 +168,7 @@ export function SearchPageContent({ searchParams }: SearchPageContentProps) { showGrantHeaders={true} showReadMoreCTA={true} hideActions={false} + showOnlyBookmark={true} noEntriesElement={ = ({ href, className, showActions = true, + showOnlyBookmark = false, showTooltips = true, maxLength, showHeader = true, @@ -447,6 +449,7 @@ export const BaseFeedItem: FC = ({ onFeedItemClick={onFeedItemClick} bounties={showBountyInfo ? undefined : content.bounties} hideReportButton={hideReportButton} + showOnlyBookmark={showOnlyBookmark} />
)} diff --git a/components/Feed/FeedContent.tsx b/components/Feed/FeedContent.tsx index 28daa100..46bded06 100644 --- a/components/Feed/FeedContent.tsx +++ b/components/Feed/FeedContent.tsx @@ -30,6 +30,7 @@ interface FeedContentProps { activeTab?: FeedTab | FundingTab | TabType | string; showBountyFooter?: boolean; hideActions?: boolean; + showOnlyBookmark?: boolean; isLoadingMore?: boolean; noEntriesElement?: ReactNode; maxLength?: number; @@ -58,6 +59,7 @@ export const FeedContent: FC = ({ activeTab, showBountyFooter = true, hideActions = false, + showOnlyBookmark = false, isLoadingMore = false, noEntriesElement, maxLength, @@ -155,6 +157,7 @@ export const FeedContent: FC = ({ index={index} showBountyFooter={showBountyFooter} hideActions={hideActions} + showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} showGrantHeaders={showGrantHeaders} showFundraiseHeaders={showFundraiseHeaders} diff --git a/components/Feed/FeedEntryItem.tsx b/components/Feed/FeedEntryItem.tsx index 5ead2f26..4b1ec0b1 100644 --- a/components/Feed/FeedEntryItem.tsx +++ b/components/Feed/FeedEntryItem.tsx @@ -31,6 +31,7 @@ interface FeedEntryItemProps { index: number; showBountyFooter?: boolean; hideActions?: boolean; + showOnlyBookmark?: boolean; maxLength?: number; showGrantHeaders?: boolean; showFundraiseHeaders?: boolean; @@ -51,6 +52,7 @@ export const FeedEntryItem: FC = ({ index, showBountyFooter = true, hideActions = false, + showOnlyBookmark = false, maxLength, showGrantHeaders = true, showFundraiseHeaders = true, @@ -204,6 +206,7 @@ export const FeedEntryItem: FC = ({ entry={entry} href={href} showActions={!hideActions} + showOnlyBookmark={showOnlyBookmark} maxLength={maxLength} onFeedItemClick={handleFeedItemClick} highlights={highlights} diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 0f921547..787b38a0 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -166,6 +166,7 @@ interface FeedItemActionsProps { relatedDocumentUnifiedDocumentId?: string; showPeerReviews?: boolean; onFeedItemClick?: () => void; + showOnlyBookmark?: boolean; // Show only the bookmark button (for search results) } // Define interface for avatar items used in local state @@ -198,12 +199,14 @@ export const FeedItemActions: FC = ({ relatedDocumentUnifiedDocumentId, showPeerReviews = true, onFeedItemClick, + showOnlyBookmark = false, }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const feedView = useFeedView(); - // UI decisions based on feed view context - const showOnlyBookmark = feedView === 'search'; + // UI decisions based on feed view context or explicit prop + // Use prop if provided, otherwise fall back to context-based decision + const shouldShowOnlyBookmark = showOnlyBookmark || feedView === 'search'; const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const [localVoteCount, setLocalVoteCount] = useState(metrics?.votes || 0); @@ -377,7 +380,6 @@ export const FeedItemActions: FC = ({ // Check if bookmark button should be shown const canShowBookmark = - userListsEnabled && relatedDocumentUnifiedDocumentId && feedContentType !== 'COMMENT' && feedContentType !== 'BOUNTY' && @@ -403,11 +405,11 @@ export const FeedItemActions: FC = ({ ); // If showOnlyBookmark, render a minimal version with just the bookmark button - if (showOnlyBookmark) { + if (shouldShowOnlyBookmark) { return ( <>
{bookmarkButton}
- {userListsEnabled && relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( + {relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( void; highlights?: Highlight[]; @@ -40,6 +41,7 @@ export const FeedItemPaper: FC = ({ href, showTooltips = true, showActions = true, + showOnlyBookmark = false, maxLength, onFeedItemClick, highlights, @@ -79,6 +81,7 @@ export const FeedItemPaper: FC = ({ entry={entry} href={paperPageUrl} showActions={showActions} + showOnlyBookmark={showOnlyBookmark} showHeader={false} showTooltips={showTooltips} customActionText={actionText} From bd2a2c3fccb1c73546bbba849f814ad5539901c8 Mon Sep 17 00:00:00 2001 From: kristian magda Date: Mon, 15 Dec 2025 08:57:29 -0600 Subject: [PATCH 8/8] hubs using namespace instead of position in array, removing redundant code --- components/Feed/FeedItemActions.tsx | 10 +++---- services/search.service.ts | 44 ++++------------------------- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 787b38a0..4aa590a7 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -409,11 +409,11 @@ export const FeedItemActions: FC = ({ return ( <>
{bookmarkButton}
- {relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( + {isAddToListModalOpen && ( )} @@ -554,7 +554,7 @@ export const FeedItemActions: FC = ({ {rightSideActionButton} {/* Show "Add to List" button in right section */} - {showPeerReviews && bookmarkButton} + {bookmarkButton} {(!hideReportButton || menuItems.length > 0) && ( = ({ /> )} - {relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( + {isAddToListModalOpen && ( )} diff --git a/services/search.service.ts b/services/search.service.ts index 0285b93d..36cea146 100644 --- a/services/search.service.ts +++ b/services/search.service.ts @@ -361,40 +361,6 @@ export class SearchService { return extendedSnippet; } - /** - * Transform journal data from API response (handles both string and object formats) - */ - private static transformJournal( - journal: - | string - | { id?: number; name?: string; slug?: string; image_url?: string } - | null - | undefined - ): { id: number; name: string; slug: string | null; imageUrl: string | null } | null { - if (!journal) { - return null; - } - - // Legacy format: journal is just a string name - if (typeof journal === 'string') { - return { - id: 0, - name: journal, - slug: journal.toLowerCase().replaceAll(/\s+/g, '-'), - imageUrl: null, - }; - } - - // New format: journal is an object - const slugFromName = journal.name?.toLowerCase().replaceAll(/\s+/g, '-') || null; - return { - id: journal.id || 0, - name: journal.name || '', - slug: journal.slug || slugFromName, - imageUrl: journal.image_url || null, - }; - } - private static transformSearchResult(doc: ApiDocumentSearchResult, query: string): FeedEntry { // First transform to a clean FeedEntry const feedEntry = this.transformDocumentToFeedEntry(doc); @@ -491,11 +457,11 @@ export class SearchService { profile_image: '', })), hub: doc.hubs?.[0] || null, - // Pass category and subcategory from first two hubs for badge display - category: doc.hubs?.[0] || null, - subcategory: doc.hubs?.[1] || null, - // Handle journal as either a string (legacy) or object (new format) - journal: this.transformJournal(doc.journal), + // Pass category and subcategory from hubs by namespace + category: doc.hubs?.find((hub) => hub.namespace === 'category') || null, + subcategory: doc.hubs?.find((hub) => hub.namespace === 'subcategory') || null, + // Only use journal if it's an object format + journal: doc.journal && typeof doc.journal === 'object' ? doc.journal : null, doi: doc.doi, citations: doc.citations || 0, score: doc.score || 0,