import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, Link } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; import Footer from "../components/layout/Footer"; import { Spinner } from "../components/ui/Spinner"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { AlertIcon, ArrowLeftIcon, DownloadIcon, SparkleIcon, UsersIcon, } from "../components/ui/Icons"; import { downloadExport, searchResearchersBulk } from "../services/api"; import { DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_PROFILE, resolveExportFromDestination, swordXmlFilename, } from "../utils/exportProfiles"; import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { useAuth } from "../contexts/AuthContext"; const EXPORT_COOLDOWN_MS = 5000; const GLOBAL_EXPORT_TOAST_ID = "group-export-global"; /** * Group results view: shows one summary card per researcher, plus a global * "download all new" (or "download everything") action in the header. * * Receives `{ orcidIds: string[] }` via `location.state` (set by LandingPage). * If no state is present the user is redirected back to `/`. */ export function GroupResultsPage() { const location = useLocation(); const navigate = useNavigate(); const { isAuthenticated } = useAuth(); const orcidIds = location.state?.orcidIds; const [results, setResults] = useState([]); const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(true); const [globalExporting, setGlobalExporting] = useState(null); // format | null const [globalExportDestination, setGlobalExportDestination] = useState( DEFAULT_EXPORT_DESTINATION, ); // Track per-researcher export state (format | null) const [cardExporting, setCardExporting] = useState({}); const [globalExportCooldownActive, setGlobalExportCooldownActive] = useState(false); const globalExportInFlightRef = useRef(false); const globalExportCooldownUntilRef = useRef(0); const globalExportCooldownTimerRef = useRef(null); const [cardExportCooldownActive, setCardExportCooldownActive] = useState( {}, ); const cardExportInFlightRef = useRef(new Set()); const cardExportCooldownUntilRef = useRef({}); const cardExportCooldownTimerRef = useRef({}); const abortRef = useRef(null); useEffect(() => { if (!orcidIds || orcidIds.length === 0) { navigate("/", { replace: true }); return; } const ctrl = new AbortController(); abortRef.current = ctrl; (async () => { setLoading(true); try { const data = await searchResearchersBulk(orcidIds, { signal: ctrl.signal, }); if (ctrl.signal.aborted) return; setResults(data.results ?? []); setErrors(data.errors ?? []); const failCount = (data.errors ?? []).length; const okCount = (data.results ?? []).length; if (failCount > 0) { toast.warning( `${okCount} investigador${okCount !== 1 ? "es" : ""} cargado${okCount !== 1 ? "s" : ""}, ${failCount} con error`, { description: "Comprueba los ORCID iDs que fallaron abajo.", }, ); } } catch (err) { if (ctrl.signal.aborted) return; toast.error("Error al buscar investigadores", { description: err?.message ?? "Inténtalo de nuevo.", }); setResults([]); setErrors([]); } finally { if (!ctrl.signal.aborted) setLoading(false); } })(); return () => ctrl.abort(); }, [orcidIds, navigate]); useEffect(() => { const cardTimersObj = cardExportCooldownTimerRef.current; return () => { const globalTimer = globalExportCooldownTimerRef.current; if (globalTimer) { clearTimeout(globalTimer); } for (const t of Object.values(cardTimersObj)) { clearTimeout(t); } }; }, []); function startGlobalExportCooldown() { globalExportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS; setGlobalExportCooldownActive(true); if (globalExportCooldownTimerRef.current) { clearTimeout(globalExportCooldownTimerRef.current); } globalExportCooldownTimerRef.current = setTimeout(() => { setGlobalExportCooldownActive(false); globalExportCooldownUntilRef.current = 0; globalExportCooldownTimerRef.current = null; }, EXPORT_COOLDOWN_MS); } function startCardExportCooldown(orcidId) { const until = Date.now() + EXPORT_COOLDOWN_MS; cardExportCooldownUntilRef.current[orcidId] = until; setCardExportCooldownActive((prev) => ({ ...prev, [orcidId]: true, })); if (cardExportCooldownTimerRef.current[orcidId]) { clearTimeout(cardExportCooldownTimerRef.current[orcidId]); } cardExportCooldownTimerRef.current[orcidId] = setTimeout(() => { setCardExportCooldownActive((prev) => ({ ...prev, [orcidId]: false, })); cardExportCooldownUntilRef.current[orcidId] = 0; cardExportCooldownTimerRef.current[orcidId] = null; }, EXPORT_COOLDOWN_MS); } // All new publication IDs across all loaded researchers const allNewIds = useMemo(() => { if (!isAuthenticated) return []; return results.flatMap((r) => (r.publications ?? []) .filter((p) => p.downloaded_by_me === false) .map((p) => p.id), ); }, [results, isAuthenticated]); const allIds = useMemo( () => results.flatMap((r) => (r.publications ?? []).map((p) => p.id)), [results], ); async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) { if ( globalExportInFlightRef.current || Date.now() < globalExportCooldownUntilRef.current ) { return; } const ids = isAuthenticated ? allNewIds : allIds; if (ids.length === 0) { toast.info( isAuthenticated ? "No hay publicaciones nuevas" : "No hay publicaciones para exportar", { id: GLOBAL_EXPORT_TOAST_ID, description: "No se encontraron publicaciones en los investigadores cargados.", }, ); return; } globalExportInFlightRef.current = true; setGlobalExporting(format); try { // For bulk export we send all IDs together, passing a placeholder orcid // since the endpoint is POST /export/{format}/publications (no orcid needed) const { blob } = await downloadExport(null, format, { publicationIds: ids, profile: format === "xml" ? profile : undefined, }); if (blob) { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = format === "xml" ? swordXmlFilename("group", profile) : `sword-group.${format}`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(objectUrl); } toast.success(`Exportación ${format.toUpperCase()} completada`, { id: GLOBAL_EXPORT_TOAST_ID, description: `${ids.length} publicaciones exportadas.`, }); } catch (err) { toast.error(`Error al exportar ${format.toUpperCase()}`, { id: GLOBAL_EXPORT_TOAST_ID, description: err?.message ?? "No se pudo generar el fichero.", }); } finally { setGlobalExporting(null); globalExportInFlightRef.current = false; startGlobalExportCooldown(); } } async function handleCardExport( orcidId, format, newIds, totalIds, profile = DEFAULT_EXPORT_PROFILE, ) { if (cardExportInFlightRef.current.has(orcidId)) { return; } const now = Date.now(); const until = cardExportCooldownUntilRef.current[orcidId] ?? 0; if (now < until) return; const ids = isAuthenticated ? newIds : totalIds; if (ids.length === 0) { toast.info("No hay publicaciones para exportar", { id: `group-export-card-${orcidId}`, }); return; } cardExportInFlightRef.current.add(orcidId); setCardExporting((prev) => ({ ...prev, [orcidId]: format })); try { const { blob } = await downloadExport(orcidId, format, { publicationIds: ids, profile: format === "xml" ? profile : undefined, }); if (blob) { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = format === "xml" ? swordXmlFilename(orcidId, profile) : `sword-${orcidId}.${format}`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(objectUrl); } toast.success(`Exportación ${format.toUpperCase()} completada`, { id: `group-export-card-${orcidId}`, description: `${ids.length} publicaciones de ${orcidId}.`, }); } catch (err) { toast.error(`Error al exportar ${format.toUpperCase()}`, { id: `group-export-card-${orcidId}`, description: err?.message ?? "No se pudo generar el fichero.", }); } finally { setCardExporting((prev) => { const next = { ...prev }; delete next[orcidId]; return next; }); cardExportInFlightRef.current.delete(orcidId); startCardExportCooldown(orcidId); } } return (
Volver al inicio {/* Page header */}

Búsqueda grupal

{!loading && (

{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} {errors.length > 0 && ( · {errors.length} con error )}

)}
{/* Global export buttons */} {!loading && results.length > 0 && ( )}
{/* Loading state */} {loading && (

Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...

Esto puede tardar unos segundos si hay muchos perfiles nuevos.

)} {/* Results grid */} {!loading && results.length > 0 && (
{results.map((bundle) => ( { const { format, profile } = resolveExportFromDestination( globalExportDestination, ); handleCardExport( bundle.researcher?.orcid_id, format, newIds, totalIds, profile ?? DEFAULT_EXPORT_PROFILE, ); }} /> ))}
)} {/* Errors */} {!loading && errors.length > 0 && (

ORCID iDs que no pudieron cargarse

{errors.map((e) => (

{e.orcid_id}

{e.detail ?? "No se pudo obtener información de este ORCID."}

))}
)} {/* Empty state */} {!loading && results.length === 0 && errors.length === 0 && (

No se encontraron resultados.

Volver al inicio
)}
); } /* ─────────────────────────── Researcher card ─────────────────────────── */ function ResearcherResultCard({ bundle, isAuthenticated, exporting, exportCooldownActive, onExport, }) { const researcher = bundle.researcher ?? {}; const publications = bundle.publications ?? []; const totalRecords = bundle.totalRecords ?? publications.length; const newIds = isAuthenticated ? publications.filter((p) => p.downloaded_by_me === false).map((p) => p.id) : []; const allPubIds = publications.map((p) => p.id); const newCount = newIds.length; const hasNew = isAuthenticated && newCount > 0; const initials = getInitials(researcher.name); return (
{/* Researcher identity */}
{initials}

{researcher.name || "Sin nombre"}

{researcher.orcid_id}
{researcher.affiliation && (

{researcher.affiliation}

)}
{/* Stats row */}

{totalRecords}

publicaciones

{isAuthenticated && ( <>

{newCount}

nuevas

)}
{/* Actions */}
Ver detalle onExport(newIds, allPubIds)} exporting={exporting} isAuthenticated={isAuthenticated} hasNew={hasNew} newCount={newCount} totalCount={totalRecords} exportCooldownActive={exportCooldownActive} />
); } /* ─────────────────────── Per-card download button ────────────────────── */ function CardExportButton({ onClick, exporting, isAuthenticated, hasNew, newCount, totalCount, exportCooldownActive, }) { const isBusy = Boolean(exporting); const disabled = isBusy || exportCooldownActive || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0); let label; if (isBusy) { label = `Descargando ${exporting.toUpperCase()}...`; } else if (exportCooldownActive) { label = "Espera un momento..."; } else if (isAuthenticated) { label = hasNew ? `Descargar (${newCount})` : "Todo descargado"; } else { label = `Descargar (${totalCount})`; } return ( ); } /* ─────────────────────────── Helpers ─────────────────────────────────── */ function getInitials(name) { if (!name) return "?"; return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((w) => w[0].toUpperCase()) .join(""); } export default GroupResultsPage;