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:
+29
-7
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
@@ -13,6 +14,23 @@ import { AuthCallbackPage } from "./pages/AuthCallbackPage";
|
||||
* can wrap `<App />` with a `MemoryRouter` if needed.
|
||||
*/
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
@@ -24,20 +42,24 @@ export default function App() {
|
||||
</Routes>
|
||||
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
position={isMobile ? "bottom-center" : "bottom-right"}
|
||||
richColors
|
||||
closeButton
|
||||
expand
|
||||
theme="light"
|
||||
visibleToasts={4}
|
||||
offset={20}
|
||||
mobileOffset={{ bottom: 12, left: 12, right: 12 }}
|
||||
toastOptions={{ duration: 4000 }}
|
||||
style={{
|
||||
/* SUCCESS — ORCID corporate green */
|
||||
'--success-bg': '#EAF3DE',
|
||||
'--success-border': '#C0DD97',
|
||||
'--success-text': '#3B6D11',
|
||||
"--success-bg": "#EAF3DE",
|
||||
"--success-border": "#C0DD97",
|
||||
"--success-text": "#3B6D11",
|
||||
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
|
||||
'--error-bg': '#F3DDDD',
|
||||
'--error-border': '#DD9797',
|
||||
'--error-text': '#6E1111',
|
||||
"--error-bg": "#F3DDDD",
|
||||
"--error-border": "#DD9797",
|
||||
"--error-text": "#6E1111",
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
export function ExportDropdown({
|
||||
onExport,
|
||||
exportingFormat = null,
|
||||
disabled = false,
|
||||
selectedCount = 0,
|
||||
isAuthenticated = false,
|
||||
newPublicationsCount = 0,
|
||||
@@ -61,7 +62,7 @@ export function ExportDropdown({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{isBusy ? (
|
||||
|
||||
@@ -5,9 +5,15 @@ import { Spinner } from "../ui/Spinner";
|
||||
* Primary action button on the dashboard. Swaps icon + colour scheme
|
||||
* 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 isSuccess = status === "success";
|
||||
const isDisabled = disabled || isLoading || isSuccess;
|
||||
|
||||
const palette = isSuccess
|
||||
? "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
|
||||
type="button"
|
||||
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()}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -80,3 +80,94 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
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:
|
||||
@@ -52,7 +58,15 @@ export function DashboardPage() {
|
||||
const [pubsError, setPubsError] = useState(null);
|
||||
|
||||
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 [exportCooldownActive, setExportCooldownActive] = useState(false);
|
||||
const exportInFlightRef = useRef(false);
|
||||
const exportCooldownUntilRef = useRef(0);
|
||||
const exportCooldownTimerRef = useRef(null);
|
||||
const [exportDestination, setExportDestination] = useState(
|
||||
DEFAULT_EXPORT_DESTINATION,
|
||||
);
|
||||
@@ -110,12 +124,60 @@ export function DashboardPage() {
|
||||
return () => ctrl.abort();
|
||||
}, [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)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
if (
|
||||
syncInFlightRef.current ||
|
||||
syncStatus !== "idle" ||
|
||||
Date.now() < syncCooldownUntilRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncInFlightRef.current = true;
|
||||
setSyncStatus("loading");
|
||||
|
||||
try {
|
||||
const bundle = await syncResearcher(orcid);
|
||||
setResearcher(bundle.researcher);
|
||||
@@ -132,6 +194,7 @@ export function DashboardPage() {
|
||||
const { newRecords, updatedRecords, totalRecords } = bundle;
|
||||
const hasChanges = newRecords > 0 || updatedRecords > 0;
|
||||
toast.success("Sincronización completada", {
|
||||
id: SYNC_TOAST_ID,
|
||||
description: hasChanges
|
||||
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
|
||||
: "Sin cambios desde la última sincronización.",
|
||||
@@ -140,12 +203,25 @@ export function DashboardPage() {
|
||||
} catch (err) {
|
||||
setSyncStatus("idle");
|
||||
toast.error("Error al sincronizar con ORCID", {
|
||||
id: SYNC_TOAST_ID,
|
||||
description: err?.message ?? "Inténtalo de nuevo más tarde.",
|
||||
});
|
||||
} finally {
|
||||
syncInFlightRef.current = false;
|
||||
startSyncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
|
||||
if (
|
||||
exportInFlightRef.current ||
|
||||
exportingFormat ||
|
||||
Date.now() < exportCooldownUntilRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportInFlightRef.current = true;
|
||||
setExportingFormat(format);
|
||||
try {
|
||||
let ids;
|
||||
@@ -157,9 +233,9 @@ export function DashboardPage() {
|
||||
ids = newPublicationIds;
|
||||
if (ids.length === 0) {
|
||||
toast.info("No hay publicaciones nuevas", {
|
||||
id: EXPORT_TOAST_ID,
|
||||
description: "Ya has descargado todas las publicaciones de este investigador.",
|
||||
});
|
||||
setExportingFormat(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -195,14 +271,18 @@ export function DashboardPage() {
|
||||
scope = "todo el investigador";
|
||||
}
|
||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||
id: EXPORT_TOAST_ID,
|
||||
description: scope,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
||||
id: EXPORT_TOAST_ID,
|
||||
description: err?.message ?? "No se pudo generar el fichero.",
|
||||
});
|
||||
} finally {
|
||||
setExportingFormat(null);
|
||||
exportInFlightRef.current = false;
|
||||
startExportCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,11 +307,13 @@ export function DashboardPage() {
|
||||
<SyncButton
|
||||
onClick={handleSync}
|
||||
status={syncStatus}
|
||||
disabled={syncDisabled}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
<ExportDropdown
|
||||
onExport={handleExport}
|
||||
exportingFormat={exportingFormat}
|
||||
disabled={exportDisabled}
|
||||
selectedCount={selectedIds.size}
|
||||
isAuthenticated={isAuthenticated}
|
||||
newPublicationsCount={newPublicationIds.length}
|
||||
|
||||
@@ -17,12 +17,15 @@ import { downloadExport, searchResearchersBulk } from "../services/api";
|
||||
import {
|
||||
DEFAULT_EXPORT_DESTINATION,
|
||||
DEFAULT_EXPORT_PROFILE,
|
||||
EXPORT_ZIP_DESTINATION,
|
||||
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.
|
||||
@@ -44,11 +47,23 @@ export function GroupResultsPage() {
|
||||
const [globalExportDestination, setGlobalExportDestination] = useState(
|
||||
DEFAULT_EXPORT_DESTINATION,
|
||||
);
|
||||
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
|
||||
|
||||
// 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(() => {
|
||||
@@ -95,6 +110,54 @@ export function GroupResultsPage() {
|
||||
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 [];
|
||||
@@ -110,25 +173,30 @@ export function GroupResultsPage() {
|
||||
[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) {
|
||||
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",
|
||||
{ description: "No se encontraron publicaciones en los investigadores cargados." },
|
||||
{
|
||||
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
|
||||
@@ -151,14 +219,18 @@ export function GroupResultsPage() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +241,22 @@ export function GroupResultsPage() {
|
||||
totalIds,
|
||||
profile = DEFAULT_EXPORT_PROFILE,
|
||||
) {
|
||||
const ids = isAuthenticated ? newIds : totalIds;
|
||||
if (ids.length === 0) {
|
||||
toast.info("No hay publicaciones para exportar");
|
||||
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, {
|
||||
@@ -194,10 +277,12 @@ export function GroupResultsPage() {
|
||||
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 {
|
||||
@@ -206,6 +291,8 @@ export function GroupResultsPage() {
|
||||
delete next[orcidId];
|
||||
return next;
|
||||
});
|
||||
cardExportInFlightRef.current.delete(orcidId);
|
||||
startCardExportCooldown(orcidId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,11 +337,14 @@ export function GroupResultsPage() {
|
||||
<ExportDropdown
|
||||
onExport={handleGlobalExport}
|
||||
exportingFormat={globalExporting}
|
||||
disabled={
|
||||
Boolean(globalExporting) || globalExportCooldownActive
|
||||
}
|
||||
selectedCount={0}
|
||||
isAuthenticated={isAuthenticated}
|
||||
newPublicationsCount={allNewIds.length}
|
||||
exportDestination={globalExportDestination}
|
||||
onExportDestinationChange={handleGlobalExportDestinationChange}
|
||||
onExportDestinationChange={setGlobalExportDestination}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,15 +371,22 @@ export function GroupResultsPage() {
|
||||
bundle={bundle}
|
||||
isAuthenticated={isAuthenticated}
|
||||
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(
|
||||
bundle.researcher?.orcid_id,
|
||||
fmt,
|
||||
format,
|
||||
newIds,
|
||||
totalIds,
|
||||
swordProfile,
|
||||
)
|
||||
}
|
||||
profile ?? DEFAULT_EXPORT_PROFILE,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -349,6 +446,7 @@ function ResearcherResultCard({
|
||||
bundle,
|
||||
isAuthenticated,
|
||||
exporting,
|
||||
exportCooldownActive,
|
||||
onExport,
|
||||
}) {
|
||||
const researcher = bundle.researcher ?? {};
|
||||
@@ -419,92 +517,64 @@ function ResearcherResultCard({
|
||||
>
|
||||
Ver detalle
|
||||
</Link>
|
||||
<ExportFormatMenu
|
||||
onExport={(fmt) => onExport(fmt, newIds, allPubIds)}
|
||||
<CardExportButton
|
||||
onClick={() => onExport(newIds, allPubIds)}
|
||||
exporting={exporting}
|
||||
isAuthenticated={isAuthenticated}
|
||||
hasNew={hasNew}
|
||||
newCount={newCount}
|
||||
totalCount={totalRecords}
|
||||
exportCooldownActive={exportCooldownActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Inline export format picker ─────────────────── */
|
||||
/* ─────────────────────── Per-card download button ────────────────────── */
|
||||
|
||||
function ExportFormatMenu({
|
||||
onExport,
|
||||
function CardExportButton({
|
||||
onClick,
|
||||
exporting,
|
||||
isAuthenticated,
|
||||
hasNew,
|
||||
newCount,
|
||||
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 disabled =
|
||||
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
|
||||
isBusy ||
|
||||
exportCooldownActive ||
|
||||
(isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
|
||||
|
||||
let label;
|
||||
if (isBusy) {
|
||||
label = `Exportando ${exporting.toUpperCase()}...`;
|
||||
label = `Descargando ${exporting.toUpperCase()}...`;
|
||||
} else if (exportCooldownActive) {
|
||||
label = "Espera un momento...";
|
||||
} else if (isAuthenticated) {
|
||||
label = hasNew ? `Nuevo (${newCount})` : "Descargado";
|
||||
label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
|
||||
} else {
|
||||
label = `Todo (${totalCount})`;
|
||||
label = `Descargar (${totalCount})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
disabled={disabled}
|
||||
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 ? (
|
||||
<Spinner size={13} />
|
||||
) : hasNew ? (
|
||||
<SparkleIcon size={11} className="text-brand-accent" />
|
||||
) : (
|
||||
<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>
|
||||
<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} />
|
||||
)}
|
||||
</div>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user