diff --git a/app/search/SearchPageContent.tsx b/app/search/SearchPageContent.tsx index f6ebfd12..8d43d304 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,25 +158,28 @@ 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 68d863e5..c9d04651 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -12,15 +12,15 @@ 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 { Button } from '@/components/ui/Button'; -import { ChevronDown } from 'lucide-react'; 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 { @@ -28,6 +28,7 @@ export interface BaseFeedItemProps { href?: string; className?: string; showActions?: boolean; + showOnlyBookmark?: boolean; showTooltips?: boolean; maxLength?: number; showHeader?: boolean; @@ -170,7 +171,8 @@ export const ContentSection: FC = ({ 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 +185,32 @@ export const ContentSection: FC = ({ ); } - // Default: render truncated plain text - return ( - - ); + // Default: render truncated plain text with expand/collapse for non-search results + if (content) { + return ( + + ); + } + return null; }; export const ImageSection: FC = ({ @@ -284,6 +289,7 @@ export const BaseFeedItem: FC = ({ href, className, showActions = true, + showOnlyBookmark = false, showTooltips = true, maxLength, showHeader = true, @@ -443,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/FeedItemAbstractSection.tsx b/components/Feed/FeedItemAbstractSection.tsx index 29d343ba..f855c8e2 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,42 @@ 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; + 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 +61,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,92 +73,53 @@ export const FeedItemAbstractSection: FC = ({ if (!content) return null; - // If we have highlighted HTML, render it (search results) - if (highlightedContent) { - return ( -
- {/* Desktop: Show content directly */} -
-

-

+ // Determine if content is truncatable + const isTextTruncated = content.length > maxLength; - {/* Mobile: Show toggle CTA */} -
- - {isMobileExpanded && ( -
-

-

- )} -
-
- ); - } + // For highlighted content, check if full content is meaningfully longer + const hasMoreContent = highlightedContent + ? content.length > stripHtml(highlightedContent).length + 50 + : isTextTruncated; + + // 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 && (

{content}

diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 6369a288..6fec04b0 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -32,6 +32,7 @@ import { PeerReviewTooltip } from '@/components/tooltips/PeerReviewTooltip'; import { BountyTooltip } from '@/components/tooltips/BountyTooltip'; import { TipTooltip } from '@/components/tooltips/TipTooltip'; import { useIsTouchDevice } from '@/hooks/useIsTouchDevice'; +import { useFeedView } from '@/contexts/FeedViewContext'; import { Tip } from '@/types/tip'; import { formatCurrency } from '@/utils/currency'; @@ -170,6 +171,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 @@ -205,8 +207,14 @@ export const FeedItemActions: FC = ({ relatedDocumentUnifiedDocumentId, showPeerReviews = true, onFeedItemClick, + showOnlyBookmark = false, }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); + const feedView = useFeedView(); + + // 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); @@ -387,6 +395,47 @@ export const FeedItemActions: FC = ({ const showInlineReviews = showPeerReviews && reviews.length > 0; const showInlineBounties = hasOpenBounties; + // Check if bookmark button should be shown + const canShowBookmark = + 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 (shouldShowOnlyBookmark) { + return ( + <> +
{bookmarkButton}
+ {isAddToListModalOpen && ( + + )} + + ); + } // Calculate total awarded amount (tips + bounty awards) const tipAmount = tips.reduce((total, tip) => total + (tip.amount || 0), 0); const totalAwarded = tipAmount + (awardedBountyAmount || 0); @@ -627,31 +676,8 @@ export const FeedItemActions: FC = ({
{rightSideActionButton} - {/* Show "Add to List" button in right section when hideReportButton is true */} - {relatedDocumentUnifiedDocumentId && - feedContentType !== 'COMMENT' && - feedContentType !== 'BOUNTY' && - feedContentType !== 'APPLICATION' && - showPeerReviews && ( - - )} + {/* Show "Add to List" button in right section */} + {bookmarkButton} {(!hideReportButton || menuItems.length > 0) && ( = ({ /> )} - {relatedDocumentUnifiedDocumentId && isAddToListModalOpen && ( + {isAddToListModalOpen && ( )} diff --git a/components/Feed/items/FeedItemPaper.tsx b/components/Feed/items/FeedItemPaper.tsx index d43e9c55..d06ff105 100644 --- a/components/Feed/items/FeedItemPaper.tsx +++ b/components/Feed/items/FeedItemPaper.tsx @@ -11,8 +11,8 @@ import { MetadataSection, FeedItemLayout, FeedItemTopSection, + FeedItemAbstractSection, } from '@/components/Feed/BaseFeedItem'; -import { FeedItemAbstractSection } from '@/components/Feed/FeedItemAbstractSection'; import { FeedItemMenuButton } from '@/components/Feed/FeedItemMenuButton'; import { FeedItemBadges } from '@/components/Feed/FeedItemBadges'; import { AuthorList } from '@/components/ui/AuthorList'; @@ -26,6 +26,7 @@ interface FeedItemPaperProps { href?: string; showTooltips?: boolean; showActions?: boolean; + showOnlyBookmark?: boolean; maxLength?: number; onFeedItemClick?: () => 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/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}; +}; diff --git a/services/search.service.ts b/services/search.service.ts index 8366b0d4..36cea146 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; @@ -456,8 +456,12 @@ 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 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, 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 { diff --git a/utils/stringUtils.ts b/utils/stringUtils.ts index 16f93443..27137122 100644 --- a/utils/stringUtils.ts +++ b/utils/stringUtils.ts @@ -15,16 +15,34 @@ 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 (const char of html) { + if (char === '<') { + inTag = true; + } else if (char === '>') { + inTag = false; + } else if (!inTag) { + result += char; + } + } + + // Clean up whitespace and HTML entities + return result + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replace(/\s+/g, ' ') .trim(); };