Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions app/expert-finder/components/ExpertSearchLiveStatus.tsx
Original file line number Diff line number Diff line change
@@ -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<ExpertSearchLiveStatusProps>) {
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'

Check warning on line 40 in app/expert-finder/components/ExpertSearchLiveStatus.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEdEoyASplG0XCcTYfS&open=AaEdEoyASplG0XCcTYfS&pullRequest=1127
: isAppendRun
? 'new experts found'
: 'experts found';

Check warning on line 43 in app/expert-finder/components/ExpertSearchLiveStatus.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEdEoyASplG0XCcTYfT&open=AaEdEoyASplG0XCcTYfT&pullRequest=1127

return (
<div
className={cn(
'rounded-xl border border-gray-200 bg-gradient-to-b from-white to-gray-50/80 px-4 py-4 shadow-sm',
className
)}
aria-live="polite"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{showSpinner ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary-600" aria-hidden />
) : null}
<span className="text-sm font-semibold text-gray-900">{statusLabel(status)}</span>
</div>
{expertsFound > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
<Users className="h-3.5 w-3.5 text-gray-500" aria-hidden />
<span className="tabular-nums font-medium">{expertsFound}</span>
<span className="text-gray-500">{expertsLabel}</span>
</span>
) : null}
</div>

<Progress value={clampedProgress} max={100} size="sm" variant="primary" className="mt-3" />

<div className="mt-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="min-w-0 flex-1 text-sm text-gray-600 transition-opacity duration-200">
{stepText}
</p>
<span className="shrink-0 text-xs font-medium tabular-nums text-gray-500">
{Math.round(clampedProgress)}%
</span>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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 =
Expand All @@ -12,10 +13,30 @@
| '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 }) {

Check warning on line 22 in app/expert-finder/library/[searchId]/components/ExpertSourceLinkIcon.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEbJ-NPD4gBRX4mmL14&open=AaEbJ-NPD4gBRX4mmL14&pullRequest=1127
return (
<svg
viewBox="0 0 24 24"
className={cn(className)}
fill="currentColor"
aria-hidden
focusable="false"
>
<circle cx="12" cy="5.25" r="2.35" />
<circle cx="5.5" cy="18.25" r="2.35" />
<circle cx="18.5" cy="18.25" r="2.35" />
<path d="M10.55 6.85 6.55 16.15a1.15 1.15 0 0 0 2.12.9l3.55-8.25a1.15 1.15 0 0 0-1.67-.95Z" />
<path d="M13.45 6.85 17.45 16.15a1.15 1.15 0 0 1-2.12.9l-3.55-8.25a1.15 1.15 0 0 1 1.67-.95Z" />
</svg>
);
}

export function getExpertSourceIconType(url: string, text?: string): ExpertSourceIconType {
const normalized = ensureAbsoluteHttpUrl(url) || url;
const combined = `${normalized} ${text ?? ''}`.toLowerCase();
Expand Down Expand Up @@ -43,6 +64,10 @@
return 'google-scholar';
}

if (hostname.includes('openalex.org') || combined.includes('openalex')) {
return 'openalex';
}

if (hostname.endsWith('.edu') || hostname.includes('.edu.')) {
return 'edu';
}
Expand Down Expand Up @@ -72,6 +97,8 @@
return (
<FontAwesomeIcon icon={faGoogle} className={`${ICON_CLASS} text-[#4285F4]`} aria-hidden />
);
case 'openalex':
return <OpenAlexIcon className={`${ICON_CLASS} text-gray-900`} />;
default:
return <ExternalLink className={`${ICON_CLASS} text-primary-600`} aria-hidden />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
}

const INITIAL_EXPERT_COUNT: ExpertCountOption = 10;

export function FindMoreExpertsModal({
isOpen,
onClose,
initialAdditionalContext,
isSubmitting,
error,
onSubmit,
}: Readonly<FindMoreExpertsModalProps>) {
const expertCountOptions = getExpertCountOptions();
const [expertCount, setExpertCount] = useState<ExpertCountOption>(() =>
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 (
<BaseModal isOpen={isOpen} onClose={onClose} title="Find more experts" size="md">
<div className="space-y-4">
<p className="text-sm text-gray-600">
Request additional experts for this search. New results are appended; existing experts
stay.
</p>

<Dropdown
label="Number of researchers"
helperText={`Up to ${expertCount} more researchers will be found`}
trigger={
<Button
type="button"
variant="outlined"
size="md"
className="w-full justify-between text-left text-gray-900 font-normal"
disabled={isSubmitting}
>
{expertCount}
<ChevronDown
className={cn(
'ml-2 h-4 w-4 shrink-0 transition-transform',
countOpen && 'rotate-180'
)}
/>
</Button>
}
className="max-h-60 overflow-y-auto py-1"
onOpenChange={setCountOpen}
>
<div className="py-1 max-h-60 overflow-y-auto">
{expertCountOptions.map((option) => (
<DropdownItem
key={option}
onClick={() => setExpertCount(option)}
className={expertCount === option ? 'bg-primary-50 text-primary-900' : ''}
>
{option}
</DropdownItem>
))}
</div>
</Dropdown>

<Textarea
label="Additional guidance (optional)"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
maxLength={EXPERT_SEARCH_ADDITIONAL_CONTEXT_MAX_LENGTH}
rows={4}
disabled={isSubmitting}
helperText="Leave unchanged to keep the current search guidance. Clearing the field replaces it with empty guidance."
/>

{error ? <Alert variant="error">{error}</Alert> : null}

<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="outlined" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<LoadingButton
type="button"
variant="default"
onClick={() => void handleSubmit()}
isLoading={isSubmitting}
>
Find more
</LoadingButton>
</div>
</div>
</BaseModal>
);
}
Loading
Loading