4262520203
Se añade un sistema de cooldown para las solicitudes de exportación en los componentes DashboardPage y GroupResultsPage, evitando el spam de notificaciones. Se optimiza el componente ExportDropdown para manejar el estado de desactivación basado en el cooldown y el estado de exportación en curso. Además, se mejora la lógica de manejo de exportaciones para asegurar una mejor experiencia de usuario.
594 lines
20 KiB
React
594 lines
20 KiB
React
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 (
|
|
<div className="flex min-h-screen flex-col bg-surface-tertiary">
|
|
<AppHeader variant="group" />
|
|
<main className="flex-1">
|
|
<div className="mx-auto w-full max-w-7xl px-4 py-7">
|
|
<Link
|
|
to="/"
|
|
className="mb-5 inline-flex items-center gap-1.5 text-sm text-ink-tertiary transition-colors hover:text-ink-primary"
|
|
>
|
|
<ArrowLeftIcon size={14} />
|
|
Volver al inicio
|
|
</Link>
|
|
|
|
{/* Page header */}
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
|
|
<UsersIcon size={20} />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-ink-primary">
|
|
Búsqueda grupal
|
|
</h1>
|
|
{!loading && (
|
|
<p className="text-xs text-ink-tertiary">
|
|
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
|
|
{errors.length > 0 && (
|
|
<span className="ml-1 text-ink-danger">
|
|
· {errors.length} con error
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Global export buttons */}
|
|
{!loading && results.length > 0 && (
|
|
<ExportDropdown
|
|
onExport={handleGlobalExport}
|
|
exportingFormat={globalExporting}
|
|
disabled={
|
|
Boolean(globalExporting) || globalExportCooldownActive
|
|
}
|
|
selectedCount={0}
|
|
isAuthenticated={isAuthenticated}
|
|
newPublicationsCount={allNewIds.length}
|
|
exportDestination={globalExportDestination}
|
|
onExportDestinationChange={setGlobalExportDestination}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Loading state */}
|
|
{loading && (
|
|
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
|
|
<Spinner size={28} />
|
|
<p className="text-sm">
|
|
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
|
|
</p>
|
|
<p className="text-xs text-ink-tertiary/60">
|
|
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results grid */}
|
|
{!loading && results.length > 0 && (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{results.map((bundle) => (
|
|
<ResearcherResultCard
|
|
key={bundle.researcher?.orcid_id}
|
|
bundle={bundle}
|
|
isAuthenticated={isAuthenticated}
|
|
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
|
|
exportCooldownActive={
|
|
cardExportCooldownActive[bundle.researcher?.orcid_id] ??
|
|
false
|
|
}
|
|
onExport={(newIds, totalIds) => {
|
|
const { format, profile } = resolveExportFromDestination(
|
|
globalExportDestination,
|
|
);
|
|
handleCardExport(
|
|
bundle.researcher?.orcid_id,
|
|
format,
|
|
newIds,
|
|
totalIds,
|
|
profile ?? DEFAULT_EXPORT_PROFILE,
|
|
);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{!loading && errors.length > 0 && (
|
|
<div className="mt-6">
|
|
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
|
|
ORCID iDs que no pudieron cargarse
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{errors.map((e) => (
|
|
<div
|
|
key={e.orcid_id}
|
|
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
|
|
>
|
|
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
|
|
<div>
|
|
<p className="font-mono text-[13px] font-medium text-red-700">
|
|
{e.orcid_id}
|
|
</p>
|
|
<p className="text-xs text-red-500">
|
|
{e.detail ?? "No se pudo obtener información de este ORCID."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!loading && results.length === 0 && errors.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
|
|
<UsersIcon size={32} className="opacity-30" />
|
|
<p className="text-sm">No se encontraron resultados.</p>
|
|
<Link
|
|
to="/"
|
|
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
|
|
>
|
|
<ArrowLeftIcon />
|
|
Volver al inicio
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────── 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 (
|
|
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-5 shadow-sm">
|
|
{/* Researcher identity */}
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
|
|
{initials}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-semibold text-ink-primary">
|
|
{researcher.name || "Sin nombre"}
|
|
</p>
|
|
<div className="mt-0.5 flex items-center gap-1">
|
|
<OrcidLogo />
|
|
<span className="truncate font-mono text-[12px] text-ink-secondary">
|
|
{researcher.orcid_id}
|
|
</span>
|
|
</div>
|
|
{researcher.affiliation && (
|
|
<p className="mt-0.5 truncate text-[12px] text-ink-tertiary">
|
|
{researcher.affiliation}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats row */}
|
|
<div className="mb-4 flex items-center gap-3 rounded-lg bg-surface-secondary px-3 py-2">
|
|
<div className="flex-1 text-center">
|
|
<p className="text-lg font-bold text-ink-primary">{totalRecords}</p>
|
|
<p className="text-[11px] text-ink-tertiary">publicaciones</p>
|
|
</div>
|
|
{isAuthenticated && (
|
|
<>
|
|
<div className="h-8 w-px bg-surface-border" />
|
|
<div className="flex-1 text-center">
|
|
<p className={`text-lg font-bold ${hasNew ? "text-brand-accent" : "text-ink-tertiary"}`}>
|
|
{newCount}
|
|
</p>
|
|
<p className="text-[11px] text-ink-tertiary">nuevas</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mt-auto flex flex-wrap gap-2">
|
|
<Link
|
|
to={`/dashboard/${researcher.orcid_id}`}
|
|
state={{ bundle }}
|
|
className="flex-1 rounded-lg border border-surface-border-strong bg-surface-secondary px-3 py-2 text-center text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-primary"
|
|
>
|
|
Ver detalle
|
|
</Link>
|
|
<CardExportButton
|
|
onClick={() => onExport(newIds, allPubIds)}
|
|
exporting={exporting}
|
|
isAuthenticated={isAuthenticated}
|
|
hasNew={hasNew}
|
|
newCount={newCount}
|
|
totalCount={totalRecords}
|
|
exportCooldownActive={exportCooldownActive}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────── 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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60 sm:flex-none"
|
|
>
|
|
{isBusy ? (
|
|
<Spinner size={13} />
|
|
) : hasNew ? (
|
|
<SparkleIcon size={11} className="text-brand-accent" />
|
|
) : (
|
|
<DownloadIcon size={13} />
|
|
)}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────── 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;
|