diff --git a/app/expert-finder/components/ExpertSearchLiveStatus.tsx b/app/expert-finder/components/ExpertSearchLiveStatus.tsx new file mode 100644 index 000000000..9d893d1cf --- /dev/null +++ b/app/expert-finder/components/ExpertSearchLiveStatus.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Loader2, Users } from 'lucide-react'; +import { Progress } from '@/components/ui/Progress'; +import { cn } from '@/utils/styles'; +import type { SearchStatus } from '@/services/expertFinder.service'; + +export interface ExpertSearchLiveStatusProps { + progress: number; + currentStep: string; + status: SearchStatus | null; + expertsFound: number; + isAppendRun?: boolean; + className?: string; +} + +function statusLabel(status: SearchStatus | null): string { + if (status === 'pending') return 'Starting…'; + if (status === 'processing') return 'Searching…'; + if (status === 'completed') return 'Complete'; + if (status === 'failed') return 'Failed'; + return 'Connecting…'; +} + +export function ExpertSearchLiveStatus({ + progress, + currentStep, + status, + expertsFound, + isAppendRun = false, + className, +}: Readonly) { + const clampedProgress = Math.min(100, Math.max(0, progress)); + const stepText = currentStep.trim() || 'Finding experts… This can take a bit of time.'; + const showSpinner = status !== 'completed' && status !== 'failed'; + const expertsLabel = + expertsFound === 1 + ? isAppendRun + ? 'new expert found' + : 'expert found' + : isAppendRun + ? 'new experts found' + : 'experts found'; + + return ( +
+
+
+ {showSpinner ? ( + + ) : null} + {statusLabel(status)} +
+ {expertsFound > 0 ? ( + + + {expertsFound} + {expertsLabel} + + ) : null} +
+ + + +
+

+ {stepText} +

+ + {Math.round(clampedProgress)}% + +
+
+ ); +} diff --git a/app/expert-finder/library/[searchId]/components/ExpertSourceLinkIcon.tsx b/app/expert-finder/library/[searchId]/components/ExpertSourceLinkIcon.tsx index 714aa0fdd..aae794111 100644 --- a/app/expert-finder/library/[searchId]/components/ExpertSourceLinkIcon.tsx +++ b/app/expert-finder/library/[searchId]/components/ExpertSourceLinkIcon.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faGoogle, faLinkedin, faOrcid, faXTwitter } from '@fortawesome/free-brands-svg-icons'; import { ExternalLink, GraduationCap } from 'lucide-react'; import type { ExpertSourceLink } from '@/types/expertFinder'; +import { cn } from '@/utils/styles'; import { ensureAbsoluteHttpUrl, isLinkedInUrl, isXUrl } from '@/utils/url'; export type ExpertSourceIconType = @@ -12,10 +13,30 @@ export type ExpertSourceIconType = | 'linkedin' | 'x' | 'google-scholar' + | 'openalex' | 'generic'; const ICON_CLASS = 'h-4 w-4 shrink-0'; +/** OpenAlex-style tricon (three joined dots / open A). Brand is black on light backgrounds. */ +function OpenAlexIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + export function getExpertSourceIconType(url: string, text?: string): ExpertSourceIconType { const normalized = ensureAbsoluteHttpUrl(url) || url; const combined = `${normalized} ${text ?? ''}`.toLowerCase(); @@ -43,6 +64,10 @@ export function getExpertSourceIconType(url: string, text?: string): ExpertSourc return 'google-scholar'; } + if (hostname.includes('openalex.org') || combined.includes('openalex')) { + return 'openalex'; + } + if (hostname.endsWith('.edu') || hostname.includes('.edu.')) { return 'edu'; } @@ -72,6 +97,8 @@ export function ExpertSourceLinkIcon({ url, text }: Pick ); + case 'openalex': + return ; default: return ; } diff --git a/app/expert-finder/library/[searchId]/components/FindMoreExpertsModal.tsx b/app/expert-finder/library/[searchId]/components/FindMoreExpertsModal.tsx new file mode 100644 index 000000000..e67124e95 --- /dev/null +++ b/app/expert-finder/library/[searchId]/components/FindMoreExpertsModal.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { BaseModal } from '@/components/ui/BaseModal'; +import { Alert } from '@/components/ui/Alert'; +import { Button } from '@/components/ui/Button'; +import { Dropdown, DropdownItem } from '@/components/ui/form/Dropdown'; +import { Textarea } from '@/components/ui/form/Textarea'; +import { LoadingButton } from '@/components/ui/LoadingButton'; +import { cn } from '@/utils/styles'; +import { + EXPERT_SEARCH_ADDITIONAL_CONTEXT_MAX_LENGTH, + clampExpertCount, + getExpertCountOptions, + type ExpertCountOption, + type FindMoreExpertsPayload, +} from '@/services/expertFinder.service'; + +export interface FindMoreExpertsModalProps { + isOpen: boolean; + onClose: () => void; + initialAdditionalContext: string; + isSubmitting: boolean; + error: string | null; + onSubmit: (payload: FindMoreExpertsPayload) => Promise; +} + +const INITIAL_EXPERT_COUNT: ExpertCountOption = 10; + +export function FindMoreExpertsModal({ + isOpen, + onClose, + initialAdditionalContext, + isSubmitting, + error, + onSubmit, +}: Readonly) { + const expertCountOptions = getExpertCountOptions(); + const [expertCount, setExpertCount] = useState(() => + clampExpertCount(INITIAL_EXPERT_COUNT) + ); + const [additionalContext, setAdditionalContext] = useState(initialAdditionalContext); + const [countOpen, setCountOpen] = useState(false); + + useEffect(() => { + if (!isOpen) return; + setExpertCount(clampExpertCount(INITIAL_EXPERT_COUNT)); + setAdditionalContext(initialAdditionalContext); + setCountOpen(false); + }, [isOpen, initialAdditionalContext]); + + const handleSubmit = async () => { + const payload: FindMoreExpertsPayload = { expert_count: expertCount }; + if (additionalContext !== initialAdditionalContext) { + payload.additional_context = additionalContext; + } + await onSubmit(payload); + }; + + return ( + +
+

+ Request additional experts for this search. New results are appended; existing experts + stay. +

+ + + {expertCount} + + + } + className="max-h-60 overflow-y-auto py-1" + onOpenChange={setCountOpen} + > +
+ {expertCountOptions.map((option) => ( + setExpertCount(option)} + className={expertCount === option ? 'bg-primary-50 text-primary-900' : ''} + > + {option} + + ))} +
+
+ +