Merge branch 'style/toasts' into 'main'

feat(ui): mejorar responsividad y experiencia de usuario en el dashboard

See merge request fjmimbre/orcid_system!4
This commit is contained in:
alexis
2026-06-03 08:13:45 +00:00
6 changed files with 360 additions and 88 deletions
+29 -7
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
@@ -13,6 +14,23 @@ import { AuthCallbackPage } from "./pages/AuthCallbackPage";
* can wrap `<App />` with a `MemoryRouter` if needed. * can wrap `<App />` with a `MemoryRouter` if needed.
*/ */
export default function App() { export default function App() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 640px)");
const updateViewport = () => {
setIsMobile(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener("change", updateViewport);
return () => {
mediaQuery.removeEventListener("change", updateViewport);
};
}, []);
return ( return (
<AuthProvider> <AuthProvider>
<Routes> <Routes>
@@ -24,20 +42,24 @@ export default function App() {
</Routes> </Routes>
<Toaster <Toaster
position="bottom-right" position={isMobile ? "bottom-center" : "bottom-right"}
richColors richColors
closeButton closeButton
expand
theme="light" theme="light"
visibleToasts={4}
offset={20}
mobileOffset={{ bottom: 12, left: 12, right: 12 }}
toastOptions={{ duration: 4000 }} toastOptions={{ duration: 4000 }}
style={{ style={{
/* SUCCESS — ORCID corporate green */ /* SUCCESS — ORCID corporate green */
'--success-bg': '#EAF3DE', "--success-bg": "#EAF3DE",
'--success-border': '#C0DD97', "--success-border": "#C0DD97",
'--success-text': '#3B6D11', "--success-text": "#3B6D11",
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */ /* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
'--error-bg': '#F3DDDD', "--error-bg": "#F3DDDD",
'--error-border': '#DD9797', "--error-border": "#DD9797",
'--error-text': '#6E1111', "--error-text": "#6E1111",
}} }}
/> />
</AuthProvider> </AuthProvider>
@@ -16,6 +16,7 @@ import {
export function ExportDropdown({ export function ExportDropdown({
onExport, onExport,
exportingFormat = null, exportingFormat = null,
disabled = false,
selectedCount = 0, selectedCount = 0,
isAuthenticated = false, isAuthenticated = false,
newPublicationsCount = 0, newPublicationsCount = 0,
@@ -61,7 +62,7 @@ export function ExportDropdown({
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={isBusy || nothingToDownload} disabled={disabled || isBusy || nothingToDownload}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto" className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto"
> >
{isBusy ? ( {isBusy ? (
@@ -5,9 +5,15 @@ import { Spinner } from "../ui/Spinner";
* Primary action button on the dashboard. Swaps icon + colour scheme * Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash). * depending on the sync lifecycle (idle → loading → success flash).
*/ */
export function SyncButton({ onClick, status = "idle", className = "" }) { export function SyncButton({
onClick,
status = "idle",
disabled = false,
className = "",
}) {
const isLoading = status === "loading"; const isLoading = status === "loading";
const isSuccess = status === "success"; const isSuccess = status === "success";
const isDisabled = disabled || isLoading || isSuccess;
const palette = isSuccess const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border" ? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
@@ -19,7 +25,7 @@ export function SyncButton({ onClick, status = "idle", className = "" }) {
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={isLoading} disabled={isDisabled}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()} className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()}
> >
{isLoading ? ( {isLoading ? (
+91
View File
@@ -80,3 +80,94 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Sonner toaster UX polish (desktop + mobile). */
[data-sonner-toaster] {
--normal-width: min(92vw, 560px);
--width: min(92vw, 560px);
--border-radius: 16px;
}
[data-sonner-toaster][data-y-position="top"] {
top: max(12px, env(safe-area-inset-top));
}
[data-sonner-toast] {
padding: 14px 16px;
border: 1px solid var(--color-surface-border-strong);
box-shadow:
0 14px 34px rgba(16, 24, 40, 0.14),
0 3px 10px rgba(16, 24, 40, 0.08);
}
[data-sonner-toast] [data-title] {
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.01em;
}
[data-sonner-toast] [data-description] {
margin-top: 4px;
font-size: 0.94rem;
line-height: 1.45;
color: var(--color-ink-secondary);
}
/* Large hit-area + clear visual state for close button. */
[data-sonner-toast] [data-close-button] {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--color-surface-border-strong);
background: #fff;
color: var(--color-ink-secondary);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}
[data-sonner-toast] [data-close-button]:hover {
background: #f8f7f3;
color: var(--color-ink-primary);
}
[data-sonner-toast] [data-close-button]:focus-visible {
outline: 2px solid var(--color-brand-accent);
outline-offset: 2px;
}
[data-sonner-toast] [data-button] {
min-height: 36px;
border-radius: 10px;
font-weight: 600;
}
@media (max-width: 640px) {
[data-sonner-toaster] {
--normal-width: min(90vw, 380px);
--width: min(90vw, 380px);
}
[data-sonner-toast] {
padding: 12px 12px 10px;
}
[data-sonner-toast] [data-title] {
font-size: 0.95rem;
line-height: 1.3;
}
[data-sonner-toast] [data-description] {
font-size: 0.9rem;
line-height: 1.4;
}
/* 44x44 touch target for thumbs on mobile. */
[data-sonner-toast] [data-close-button] {
width: 40px;
height: 40px;
}
[data-sonner-toast] [data-button] {
min-height: 40px;
}
}
+83 -1
View File
@@ -23,6 +23,12 @@ import {
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000; const SUCCESS_FLASH_MS = 3000;
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
const SYNC_COOLDOWN_MS = 5000;
const SYNC_TOAST_ID = "researcher-sync";
/** Minimum gap between export requests (protects backend + avoids toast spam). */
const EXPORT_COOLDOWN_MS = 5000;
const EXPORT_TOAST_ID = "researcher-export";
/** /**
* Researcher detail page. Owns: * Researcher detail page. Owns:
@@ -52,7 +58,15 @@ export function DashboardPage() {
const [pubsError, setPubsError] = useState(null); const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [syncCooldownActive, setSyncCooldownActive] = useState(false);
const syncInFlightRef = useRef(false);
const syncCooldownUntilRef = useRef(0);
const syncCooldownTimerRef = useRef(null);
const [exportingFormat, setExportingFormat] = useState(null); const [exportingFormat, setExportingFormat] = useState(null);
const [exportCooldownActive, setExportCooldownActive] = useState(false);
const exportInFlightRef = useRef(false);
const exportCooldownUntilRef = useRef(0);
const exportCooldownTimerRef = useRef(null);
const [exportDestination, setExportDestination] = useState( const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
); );
@@ -110,12 +124,60 @@ export function DashboardPage() {
return () => ctrl.abort(); return () => ctrl.abort();
}, [orcid, loadBundle]); }, [orcid, loadBundle]);
useEffect(() => {
return () => {
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
};
}, []);
const syncDisabled = syncStatus !== "idle" || syncCooldownActive;
const exportDisabled = Boolean(exportingFormat) || exportCooldownActive;
function startSyncCooldown() {
syncCooldownUntilRef.current = Date.now() + SYNC_COOLDOWN_MS;
setSyncCooldownActive(true);
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
syncCooldownTimerRef.current = setTimeout(() => {
setSyncCooldownActive(false);
syncCooldownTimerRef.current = null;
}, SYNC_COOLDOWN_MS);
}
function startExportCooldown() {
exportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
setExportCooldownActive(true);
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
exportCooldownTimerRef.current = setTimeout(() => {
setExportCooldownActive(false);
exportCooldownTimerRef.current = null;
}, EXPORT_COOLDOWN_MS);
}
if (!isValidOrcid(orcid)) { if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
async function handleSync() { async function handleSync() {
if (
syncInFlightRef.current ||
syncStatus !== "idle" ||
Date.now() < syncCooldownUntilRef.current
) {
return;
}
syncInFlightRef.current = true;
setSyncStatus("loading"); setSyncStatus("loading");
try { try {
const bundle = await syncResearcher(orcid); const bundle = await syncResearcher(orcid);
setResearcher(bundle.researcher); setResearcher(bundle.researcher);
@@ -132,6 +194,7 @@ export function DashboardPage() {
const { newRecords, updatedRecords, totalRecords } = bundle; const { newRecords, updatedRecords, totalRecords } = bundle;
const hasChanges = newRecords > 0 || updatedRecords > 0; const hasChanges = newRecords > 0 || updatedRecords > 0;
toast.success("Sincronización completada", { toast.success("Sincronización completada", {
id: SYNC_TOAST_ID,
description: hasChanges description: hasChanges
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).` ? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
: "Sin cambios desde la última sincronización.", : "Sin cambios desde la última sincronización.",
@@ -140,12 +203,25 @@ export function DashboardPage() {
} catch (err) { } catch (err) {
setSyncStatus("idle"); setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", { toast.error("Error al sincronizar con ORCID", {
id: SYNC_TOAST_ID,
description: err?.message ?? "Inténtalo de nuevo más tarde.", description: err?.message ?? "Inténtalo de nuevo más tarde.",
}); });
} finally {
syncInFlightRef.current = false;
startSyncCooldown();
} }
} }
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) { async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
if (
exportInFlightRef.current ||
exportingFormat ||
Date.now() < exportCooldownUntilRef.current
) {
return;
}
exportInFlightRef.current = true;
setExportingFormat(format); setExportingFormat(format);
try { try {
let ids; let ids;
@@ -157,9 +233,9 @@ export function DashboardPage() {
ids = newPublicationIds; ids = newPublicationIds;
if (ids.length === 0) { if (ids.length === 0) {
toast.info("No hay publicaciones nuevas", { toast.info("No hay publicaciones nuevas", {
id: EXPORT_TOAST_ID,
description: "Ya has descargado todas las publicaciones de este investigador.", description: "Ya has descargado todas las publicaciones de este investigador.",
}); });
setExportingFormat(null);
return; return;
} }
} else { } else {
@@ -195,14 +271,18 @@ export function DashboardPage() {
scope = "todo el investigador"; scope = "todo el investigador";
} }
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: EXPORT_TOAST_ID,
description: scope, description: scope,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
setExportingFormat(null); setExportingFormat(null);
exportInFlightRef.current = false;
startExportCooldown();
} }
} }
@@ -227,11 +307,13 @@ export function DashboardPage() {
<SyncButton <SyncButton
onClick={handleSync} onClick={handleSync}
status={syncStatus} status={syncStatus}
disabled={syncDisabled}
className="w-full sm:w-auto" className="w-full sm:w-auto"
/> />
<ExportDropdown <ExportDropdown
onExport={handleExport} onExport={handleExport}
exportingFormat={exportingFormat} exportingFormat={exportingFormat}
disabled={exportDisabled}
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length} newPublicationsCount={newPublicationIds.length}
+147 -77
View File
@@ -17,12 +17,15 @@ import { downloadExport, searchResearchersBulk } from "../services/api";
import { import {
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
DEFAULT_EXPORT_PROFILE, DEFAULT_EXPORT_PROFILE,
EXPORT_ZIP_DESTINATION, resolveExportFromDestination,
swordXmlFilename, swordXmlFilename,
} from "../utils/exportProfiles"; } from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext"; 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 * Group results view: shows one summary card per researcher, plus a global
* "download all new" (or "download everything") action in the header. * "download all new" (or "download everything") action in the header.
@@ -44,11 +47,23 @@ export function GroupResultsPage() {
const [globalExportDestination, setGlobalExportDestination] = useState( const [globalExportDestination, setGlobalExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
); );
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
// Track per-researcher export state (format | null) // Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({}); 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); const abortRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -95,6 +110,54 @@ export function GroupResultsPage() {
return () => ctrl.abort(); return () => ctrl.abort();
}, [orcidIds, navigate]); }, [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 // All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => { const allNewIds = useMemo(() => {
if (!isAuthenticated) return []; if (!isAuthenticated) return [];
@@ -110,25 +173,30 @@ export function GroupResultsPage() {
[results], [results],
); );
function handleGlobalExportDestinationChange(nextDestination) {
setGlobalExportDestination(nextDestination);
// Keep last XML profile for card-level exports.
if (nextDestination !== EXPORT_ZIP_DESTINATION) {
setSwordProfile(nextDestination);
}
}
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) { async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
if (
globalExportInFlightRef.current ||
Date.now() < globalExportCooldownUntilRef.current
) {
return;
}
const ids = isAuthenticated ? allNewIds : allIds; const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) { if (ids.length === 0) {
toast.info( toast.info(
isAuthenticated isAuthenticated
? "No hay publicaciones nuevas" ? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar", : "No hay publicaciones para exportar",
{ description: "No se encontraron publicaciones en los investigadores cargados." }, {
id: GLOBAL_EXPORT_TOAST_ID,
description:
"No se encontraron publicaciones en los investigadores cargados.",
},
); );
return; return;
} }
globalExportInFlightRef.current = true;
setGlobalExporting(format); setGlobalExporting(format);
try { try {
// For bulk export we send all IDs together, passing a placeholder orcid // For bulk export we send all IDs together, passing a placeholder orcid
@@ -151,14 +219,18 @@ export function GroupResultsPage() {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: `${ids.length} publicaciones exportadas.`, description: `${ids.length} publicaciones exportadas.`,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
setGlobalExporting(null); setGlobalExporting(null);
globalExportInFlightRef.current = false;
startGlobalExportCooldown();
} }
} }
@@ -169,11 +241,22 @@ export function GroupResultsPage() {
totalIds, totalIds,
profile = DEFAULT_EXPORT_PROFILE, profile = DEFAULT_EXPORT_PROFILE,
) { ) {
const ids = isAuthenticated ? newIds : totalIds; if (cardExportInFlightRef.current.has(orcidId)) {
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar");
return; 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 })); setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try { try {
const { blob } = await downloadExport(orcidId, format, { const { blob } = await downloadExport(orcidId, format, {
@@ -194,10 +277,12 @@ export function GroupResultsPage() {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: `group-export-card-${orcidId}`,
description: `${ids.length} publicaciones de ${orcidId}.`, description: `${ids.length} publicaciones de ${orcidId}.`,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: `group-export-card-${orcidId}`,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
@@ -206,6 +291,8 @@ export function GroupResultsPage() {
delete next[orcidId]; delete next[orcidId];
return next; return next;
}); });
cardExportInFlightRef.current.delete(orcidId);
startCardExportCooldown(orcidId);
} }
} }
@@ -250,11 +337,14 @@ export function GroupResultsPage() {
<ExportDropdown <ExportDropdown
onExport={handleGlobalExport} onExport={handleGlobalExport}
exportingFormat={globalExporting} exportingFormat={globalExporting}
disabled={
Boolean(globalExporting) || globalExportCooldownActive
}
selectedCount={0} selectedCount={0}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
newPublicationsCount={allNewIds.length} newPublicationsCount={allNewIds.length}
exportDestination={globalExportDestination} exportDestination={globalExportDestination}
onExportDestinationChange={handleGlobalExportDestinationChange} onExportDestinationChange={setGlobalExportDestination}
/> />
)} )}
</div> </div>
@@ -281,15 +371,22 @@ export function GroupResultsPage() {
bundle={bundle} bundle={bundle}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null} exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) => exportCooldownActive={
cardExportCooldownActive[bundle.researcher?.orcid_id] ??
false
}
onExport={(newIds, totalIds) => {
const { format, profile } = resolveExportFromDestination(
globalExportDestination,
);
handleCardExport( handleCardExport(
bundle.researcher?.orcid_id, bundle.researcher?.orcid_id,
fmt, format,
newIds, newIds,
totalIds, totalIds,
swordProfile, profile ?? DEFAULT_EXPORT_PROFILE,
) );
} }}
/> />
))} ))}
</div> </div>
@@ -349,6 +446,7 @@ function ResearcherResultCard({
bundle, bundle,
isAuthenticated, isAuthenticated,
exporting, exporting,
exportCooldownActive,
onExport, onExport,
}) { }) {
const researcher = bundle.researcher ?? {}; const researcher = bundle.researcher ?? {};
@@ -419,92 +517,64 @@ function ResearcherResultCard({
> >
Ver detalle Ver detalle
</Link> </Link>
<ExportFormatMenu <CardExportButton
onExport={(fmt) => onExport(fmt, newIds, allPubIds)} onClick={() => onExport(newIds, allPubIds)}
exporting={exporting} exporting={exporting}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
hasNew={hasNew} hasNew={hasNew}
newCount={newCount} newCount={newCount}
totalCount={totalRecords} totalCount={totalRecords}
exportCooldownActive={exportCooldownActive}
/> />
</div> </div>
</div> </div>
); );
} }
/* ─────────────────────── Inline export format picker ─────────────────── */ /* ─────────────────────── Per-card download button ────────────────────── */
function ExportFormatMenu({ function CardExportButton({
onExport, onClick,
exporting, exporting,
isAuthenticated, isAuthenticated,
hasNew, hasNew,
newCount, newCount,
totalCount, totalCount,
exportCooldownActive,
}) { }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exporting); const isBusy = Boolean(exporting);
const disabled = const disabled =
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0); isBusy ||
exportCooldownActive ||
(isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
let label; let label;
if (isBusy) { if (isBusy) {
label = `Exportando ${exporting.toUpperCase()}...`; label = `Descargando ${exporting.toUpperCase()}...`;
} else if (exportCooldownActive) {
label = "Espera un momento...";
} else if (isAuthenticated) { } else if (isAuthenticated) {
label = hasNew ? `Nuevo (${newCount})` : "Descargado"; label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
} else { } else {
label = `Todo (${totalCount})`; label = `Descargar (${totalCount})`;
} }
return ( return (
<div className="relative" ref={ref}> <button
<button type="button"
type="button" onClick={onClick}
onClick={() => setOpen((o) => !o)} disabled={disabled}
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"
className="inline-flex items-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" >
> {isBusy ? (
{isBusy ? ( <Spinner size={13} />
<Spinner size={13} /> ) : hasNew ? (
) : hasNew ? ( <SparkleIcon size={11} className="text-brand-accent" />
<SparkleIcon size={11} className="text-brand-accent" /> ) : (
) : ( <DownloadIcon size={13} />
<DownloadIcon size={13} />
)}
{label}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+4px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{["xml", "zip"].map((fmt, idx) => (
<button
key={fmt}
type="button"
onClick={() => {
setOpen(false);
onExport(fmt);
}}
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-secondary ${
idx === 0 ? "border-b border-surface-border/60" : ""
}`}
>
<DownloadIcon size={13} />
{fmt.toUpperCase()}
</button>
))}
</div>
)} )}
</div> {label}
</button>
); );
} }